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