1use crate::filtered_lines::FilteredLinesExt;
2use regex::{Regex, RegexBuilder};
3
4use crate::rule::{FixCapability, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
5use crate::utils::range_utils::byte_to_char_count;
6
7mod md061_config;
8pub(super) use md061_config::MD061Config;
9
10#[derive(Debug, Clone, Default)]
15pub struct MD061ForbiddenTerms {
16 config: MD061Config,
17 pattern: Option<Regex>,
18}
19
20impl MD061ForbiddenTerms {
21 pub fn new(terms: Vec<String>, case_sensitive: bool) -> Self {
22 let config = MD061Config { terms, case_sensitive };
23 let pattern = Self::build_pattern(&config);
24 Self { config, pattern }
25 }
26
27 pub fn from_config_struct(config: MD061Config) -> Self {
28 let pattern = Self::build_pattern(&config);
29 Self { config, pattern }
30 }
31
32 fn build_pattern(config: &MD061Config) -> Option<Regex> {
33 if config.terms.is_empty() {
34 return None;
35 }
36
37 let escaped_terms: Vec<String> = config.terms.iter().map(|term| regex::escape(term)).collect();
39 let pattern_str = escaped_terms.join("|");
40
41 RegexBuilder::new(&pattern_str)
42 .case_insensitive(!config.case_sensitive)
43 .build()
44 .ok()
45 }
46
47 fn is_word_boundary(content: &str, start: usize, end: usize) -> bool {
49 let before_ok = if start == 0 {
50 true
51 } else {
52 content[..start]
53 .chars()
54 .last()
55 .is_none_or(|c| !c.is_alphanumeric() && c != '_')
56 };
57
58 let after_ok = if end >= content.len() {
59 true
60 } else {
61 content[end..]
62 .chars()
63 .next()
64 .is_none_or(|c| !c.is_alphanumeric() && c != '_')
65 };
66
67 before_ok && after_ok
68 }
69}
70
71impl Rule for MD061ForbiddenTerms {
72 fn name(&self) -> &'static str {
73 "MD061"
74 }
75
76 fn description(&self) -> &'static str {
77 "Forbidden terms"
78 }
79
80 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
81 let Some(pattern) = &self.pattern else {
83 return Ok(Vec::new());
84 };
85
86 let mut warnings = Vec::new();
87
88 for line in ctx
90 .filtered_lines()
91 .skip_front_matter()
92 .skip_code_blocks()
93 .skip_html_comments()
94 .skip_jsx_expressions()
95 .skip_mdx_comments()
96 .skip_obsidian_comments()
97 {
98 let content = line.content;
99
100 for mat in pattern.find_iter(content) {
102 if ctx.is_in_code_span(line.line_num, byte_to_char_count(content, mat.start())) {
104 continue;
105 }
106
107 if !Self::is_word_boundary(content, mat.start(), mat.end()) {
109 continue;
110 }
111
112 let matched_term = &content[mat.start()..mat.end()];
113 let display_term = if self.config.case_sensitive {
114 matched_term.to_string()
115 } else {
116 matched_term.to_uppercase()
117 };
118
119 warnings.push(LintWarning {
120 rule_name: Some(self.name().to_string()),
121 severity: Severity::Warning,
122 message: format!("Found forbidden term '{display_term}'"),
123 line: line.line_num,
124 column: byte_to_char_count(content, mat.start()),
125 end_line: line.line_num,
126 end_column: byte_to_char_count(content, mat.end()),
127 fix: None, });
129 }
130 }
131
132 Ok(warnings)
133 }
134
135 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
136 Ok(ctx.content.to_string())
137 }
138
139 fn category(&self) -> RuleCategory {
140 RuleCategory::Other
141 }
142
143 fn fix_capability(&self) -> FixCapability {
144 FixCapability::Unfixable
145 }
146
147 fn as_any(&self) -> &dyn std::any::Any {
148 self
149 }
150
151 fn should_skip(&self, _ctx: &crate::lint_context::LintContext) -> bool {
152 self.config.terms.is_empty()
154 }
155
156 crate::impl_rule_config_methods!(MD061Config);
157}
158
159#[cfg(test)]
160mod tests {
161 use super::*;
162 use crate::config::MarkdownFlavor;
163 use crate::lint_context::LintContext;
164
165 #[test]
166 fn test_empty_config_no_warnings() {
167 let rule = MD061ForbiddenTerms::default();
168 let content = "# TODO: This should not trigger\n\nFIXME: This too\n";
169 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
170 let result = rule.check(&ctx).unwrap();
171 assert!(result.is_empty());
172 }
173
174 #[test]
175 fn test_configured_terms_detected() {
176 let rule = MD061ForbiddenTerms::new(vec!["TODO".to_string(), "FIXME".to_string()], false);
177 let content = "# Heading\n\nTODO: Implement this\n\nFIXME: Fix this bug\n";
178 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
179 let result = rule.check(&ctx).unwrap();
180 assert_eq!(result.len(), 2);
181 assert!(result[0].message.contains("forbidden term"));
182 assert!(result[0].message.contains("TODO"));
183 assert!(result[1].message.contains("forbidden term"));
184 assert!(result[1].message.contains("FIXME"));
185 }
186
187 #[test]
188 fn test_case_sensitive_by_default() {
189 let config = MD061Config {
191 terms: vec!["TODO".to_string()],
192 ..Default::default()
193 };
194 let rule = MD061ForbiddenTerms::from_config_struct(config);
195 let content = "todo: lowercase\nTODO: uppercase\nTodo: mixed\n";
196 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
197 let result = rule.check(&ctx).unwrap();
198 assert_eq!(result.len(), 1);
199 assert_eq!(result[0].line, 2); }
201
202 #[test]
203 fn test_case_insensitive_opt_in() {
204 let rule = MD061ForbiddenTerms::new(vec!["TODO".to_string()], false);
205 let content = "todo: lowercase\nTODO: uppercase\nTodo: mixed\n";
206 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
207 let result = rule.check(&ctx).unwrap();
208 assert_eq!(result.len(), 3);
209 }
210
211 #[test]
212 fn test_case_sensitive_mode() {
213 let rule = MD061ForbiddenTerms::new(vec!["TODO".to_string()], true);
214 let content = "todo: lowercase\nTODO: uppercase\nTodo: mixed\n";
215 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
216 let result = rule.check(&ctx).unwrap();
217 assert_eq!(result.len(), 1);
218 assert_eq!(result[0].line, 2);
219 }
220
221 #[test]
222 fn test_word_boundary_no_false_positive() {
223 let rule = MD061ForbiddenTerms::new(vec!["TODO".to_string()], false);
224 let content = "TODOMORROW is not a match\nTODO is a match\n";
225 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
226 let result = rule.check(&ctx).unwrap();
227 assert_eq!(result.len(), 1);
228 assert_eq!(result[0].line, 2);
229 }
230
231 #[test]
232 fn test_word_boundary_with_punctuation() {
233 let rule = MD061ForbiddenTerms::new(vec!["TODO".to_string()], false);
234 let content = "TODO: colon\nTODO. period\n(TODO) parens\n";
235 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
236 let result = rule.check(&ctx).unwrap();
237 assert_eq!(result.len(), 3);
238 }
239
240 #[test]
241 fn test_skip_fenced_code_block() {
242 let rule = MD061ForbiddenTerms::new(vec!["TODO".to_string()], false);
243 let content = "# Heading\n\n```\nTODO: in code block\n```\n\nTODO: outside\n";
244 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
245 let result = rule.check(&ctx).unwrap();
246 assert_eq!(result.len(), 1);
247 assert_eq!(result[0].line, 7);
248 }
249
250 #[test]
251 fn test_skip_indented_code_block() {
252 let rule = MD061ForbiddenTerms::new(vec!["TODO".to_string()], false);
253 let content = "# Heading\n\n TODO: in indented code\n\nTODO: outside\n";
254 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
255 let result = rule.check(&ctx).unwrap();
256 assert_eq!(result.len(), 1);
257 assert_eq!(result[0].line, 5);
258 }
259
260 #[test]
261 fn test_skip_inline_code() {
262 let rule = MD061ForbiddenTerms::new(vec!["TODO".to_string()], false);
263 let content = "Here is `TODO` in inline code\nTODO: outside inline\n";
264 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
265 let result = rule.check(&ctx).unwrap();
266 assert_eq!(result.len(), 1);
267 assert_eq!(result[0].line, 2);
268 }
269
270 #[test]
271 fn test_skip_frontmatter() {
272 let rule = MD061ForbiddenTerms::new(vec!["TODO".to_string()], false);
273 let content = "---\ntitle: TODO in frontmatter\n---\n\nTODO: outside\n";
274 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
275 let result = rule.check(&ctx).unwrap();
276 assert_eq!(result.len(), 1);
277 assert_eq!(result[0].line, 5);
278 }
279
280 #[test]
281 fn test_multiple_terms_on_same_line() {
282 let rule = MD061ForbiddenTerms::new(vec!["TODO".to_string(), "FIXME".to_string()], false);
283 let content = "TODO: first thing FIXME: second thing\n";
284 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
285 let result = rule.check(&ctx).unwrap();
286 assert_eq!(result.len(), 2);
287 }
288
289 #[test]
290 fn test_term_at_start_of_line() {
291 let rule = MD061ForbiddenTerms::new(vec!["TODO".to_string()], false);
292 let content = "TODO at start\n";
293 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
294 let result = rule.check(&ctx).unwrap();
295 assert_eq!(result.len(), 1);
296 assert_eq!(result[0].column, 1);
297 }
298
299 #[test]
300 fn test_term_at_end_of_line() {
301 let rule = MD061ForbiddenTerms::new(vec!["TODO".to_string()], false);
302 let content = "something TODO\n";
303 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
304 let result = rule.check(&ctx).unwrap();
305 assert_eq!(result.len(), 1);
306 }
307
308 #[test]
309 fn test_custom_terms() {
310 let rule = MD061ForbiddenTerms::new(vec!["HACK".to_string(), "XXX".to_string()], false);
311 let content = "HACK: workaround\nXXX: needs review\nTODO: not configured\n";
312 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
313 let result = rule.check(&ctx).unwrap();
314 assert_eq!(result.len(), 2);
315 }
316
317 #[test]
318 fn test_no_fix_available() {
319 let rule = MD061ForbiddenTerms::new(vec!["TODO".to_string()], false);
320 let content = "TODO: something\n";
321 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
322 let result = rule.check(&ctx).unwrap();
323 assert_eq!(result.len(), 1);
324 assert!(result[0].fix.is_none());
325 }
326
327 #[test]
328 fn test_column_positions() {
329 let rule = MD061ForbiddenTerms::new(vec!["TODO".to_string()], false);
330 let content = " TODO: indented\n";
332 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
333 let result = rule.check(&ctx).unwrap();
334 assert_eq!(result.len(), 1);
335 assert_eq!(result[0].column, 3); assert_eq!(result[0].end_column, 7);
337 }
338
339 #[test]
340 fn test_config_from_toml() {
341 let mut config = crate::config::Config::default();
342 let mut rule_config = crate::config::RuleConfig::default();
343 rule_config.values.insert(
344 "terms".to_string(),
345 toml::Value::Array(vec![toml::Value::String("FIXME".to_string())]),
346 );
347 config.rules.insert("MD061".to_string(), rule_config);
348
349 let rule = MD061ForbiddenTerms::from_config(&config);
350 let content = "FIXME: configured\nTODO: not configured\n";
351 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
352 let result = rule.check(&ctx).unwrap();
353 assert_eq!(result.len(), 1);
354 assert!(result[0].message.contains("forbidden term"));
355 assert!(result[0].message.contains("FIXME"));
356 }
357
358 #[test]
359 fn test_config_from_toml_case_sensitive_by_default() {
360 let mut config = crate::config::Config::default();
363 let mut rule_config = crate::config::RuleConfig::default();
364 rule_config.values.insert(
365 "terms".to_string(),
366 toml::Value::Array(vec![toml::Value::String("TODO".to_string())]),
367 );
368 config.rules.insert("MD061".to_string(), rule_config);
369
370 let rule = MD061ForbiddenTerms::from_config(&config);
371 let content = "todo: lowercase\nTODO: uppercase\nTodo: mixed\n";
372 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
373 let result = rule.check(&ctx).unwrap();
374
375 assert_eq!(result.len(), 1);
377 assert_eq!(result[0].line, 2);
378 }
379
380 #[test]
381 fn test_skip_html_comment() {
382 let rule = MD061ForbiddenTerms::new(vec!["TODO".to_string()], false);
383 let content = "<!-- TODO: in html comment -->\nTODO: outside\n";
384 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
385 let result = rule.check(&ctx).unwrap();
386 assert_eq!(result.len(), 1);
387 assert_eq!(result[0].line, 2);
388 }
389
390 #[test]
391 fn test_skip_double_backtick_inline_code() {
392 let rule = MD061ForbiddenTerms::new(vec!["TODO".to_string()], false);
393 let content = "Here is ``TODO`` in double backticks\nTODO: outside\n";
394 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
395 let result = rule.check(&ctx).unwrap();
396 assert_eq!(result.len(), 1);
397 assert_eq!(result[0].line, 2);
398 }
399
400 #[test]
401 fn test_skip_triple_backtick_inline_code() {
402 let rule = MD061ForbiddenTerms::new(vec!["TODO".to_string()], false);
403 let content = "Here is ```TODO``` in triple backticks\nTODO: outside\n";
404 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
405 let result = rule.check(&ctx).unwrap();
406 assert_eq!(result.len(), 1);
407 assert_eq!(result[0].line, 2);
408 }
409
410 #[test]
411 fn test_inline_code_with_backtick_content() {
412 let rule = MD061ForbiddenTerms::new(vec!["TODO".to_string()], false);
413 let content = "Use `` `TODO` `` to show a backtick\nTODO: outside\n";
415 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
416 let result = rule.check(&ctx).unwrap();
417 assert_eq!(result.len(), 1);
418 assert_eq!(result[0].line, 2);
419 }
420}