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()];
117
118 warnings.push(LintWarning {
119 rule_name: Some(self.name().to_string()),
120 severity: Severity::Warning,
121 message: format!("Found forbidden term '{matched_term}'"),
122 line: line.line_num,
123 column: byte_to_char_count(content, mat.start()),
124 end_line: line.line_num,
125 end_column: byte_to_char_count(content, mat.end()),
126 fix: None, });
128 }
129 }
130
131 Ok(warnings)
132 }
133
134 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
135 Ok(ctx.content.to_string())
136 }
137
138 fn category(&self) -> RuleCategory {
139 RuleCategory::Other
140 }
141
142 fn fix_capability(&self) -> FixCapability {
143 FixCapability::Unfixable
144 }
145
146 fn as_any(&self) -> &dyn std::any::Any {
147 self
148 }
149
150 fn should_skip(&self, _ctx: &crate::lint_context::LintContext) -> bool {
151 self.config.terms.is_empty()
153 }
154
155 crate::impl_rule_config_methods!(MD061Config);
156}
157
158#[cfg(test)]
159mod tests {
160 use super::*;
161 use crate::config::MarkdownFlavor;
162 use crate::lint_context::LintContext;
163
164 #[test]
165 fn test_empty_config_no_warnings() {
166 let rule = MD061ForbiddenTerms::default();
167 let content = "# TODO: This should not trigger\n\nFIXME: This too\n";
168 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
169 let result = rule.check(&ctx).unwrap();
170 assert!(result.is_empty());
171 }
172
173 #[test]
174 fn test_configured_terms_detected() {
175 let rule = MD061ForbiddenTerms::new(vec!["TODO".to_string(), "FIXME".to_string()], false);
176 let content = "# Heading\n\nTODO: Implement this\n\nFIXME: Fix this bug\n";
177 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
178 let result = rule.check(&ctx).unwrap();
179 assert_eq!(result.len(), 2);
180 assert!(result[0].message.contains("forbidden term"));
181 assert!(result[0].message.contains("TODO"));
182 assert!(result[1].message.contains("forbidden term"));
183 assert!(result[1].message.contains("FIXME"));
184 }
185
186 #[test]
187 fn test_case_sensitive_by_default() {
188 let config = MD061Config {
190 terms: vec!["TODO".to_string()],
191 ..Default::default()
192 };
193 let rule = MD061ForbiddenTerms::from_config_struct(config);
194 let content = "todo: lowercase\nTODO: uppercase\nTodo: mixed\n";
195 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
196 let result = rule.check(&ctx).unwrap();
197 assert_eq!(result.len(), 1);
198 assert_eq!(result[0].line, 2); }
200
201 #[test]
202 fn test_case_insensitive_opt_in() {
203 let rule = MD061ForbiddenTerms::new(vec!["TODO".to_string()], false);
204 let content = "todo: lowercase\nTODO: uppercase\nTodo: mixed\n";
205 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
206 let result = rule.check(&ctx).unwrap();
207 assert_eq!(result.len(), 3);
208 }
209
210 #[test]
211 fn test_message_quotes_document_casing() {
212 let rule = MD061ForbiddenTerms::new(vec!["delve".to_string()], false);
215 let content = "We Delve into it.\n";
216 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
217 let result = rule.check(&ctx).unwrap();
218 assert_eq!(result.len(), 1);
219 assert_eq!(result[0].message, "Found forbidden term 'Delve'");
220 }
221
222 #[test]
223 fn test_message_quotes_multi_word_document_casing() {
224 let rule = MD061ForbiddenTerms::new(vec!["at the end of the day".to_string()], false);
225 let content = "At the end of the day it ships.\n";
226 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
227 let result = rule.check(&ctx).unwrap();
228 assert_eq!(result.len(), 1);
229 assert_eq!(result[0].message, "Found forbidden term 'At the end of the day'");
230 }
231
232 #[test]
233 fn test_message_quotes_exact_text_when_case_sensitive() {
234 let rule = MD061ForbiddenTerms::new(vec!["TODO".to_string()], true);
235 let content = "TODO: something\n";
236 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
237 let result = rule.check(&ctx).unwrap();
238 assert_eq!(result.len(), 1);
239 assert_eq!(result[0].message, "Found forbidden term 'TODO'");
240 }
241
242 #[test]
243 fn test_message_quotes_what_the_range_covers() {
244 let rule = MD061ForbiddenTerms::new(vec!["delve".to_string()], false);
247 let content = "你好 Delve deeper\n";
248 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
249 let result = rule.check(&ctx).unwrap();
250 assert_eq!(result.len(), 1);
251
252 let warning = &result[0];
253 let line: Vec<char> = content.lines().next().unwrap().chars().collect();
254 let spanned: String = line[warning.column - 1..warning.end_column - 1].iter().collect();
255 assert_eq!(spanned, "Delve");
256 assert_eq!(warning.message, format!("Found forbidden term '{spanned}'"));
257 }
258
259 #[test]
260 fn test_case_sensitive_mode() {
261 let rule = MD061ForbiddenTerms::new(vec!["TODO".to_string()], true);
262 let content = "todo: lowercase\nTODO: uppercase\nTodo: mixed\n";
263 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
264 let result = rule.check(&ctx).unwrap();
265 assert_eq!(result.len(), 1);
266 assert_eq!(result[0].line, 2);
267 }
268
269 #[test]
270 fn test_word_boundary_no_false_positive() {
271 let rule = MD061ForbiddenTerms::new(vec!["TODO".to_string()], false);
272 let content = "TODOMORROW is not a match\nTODO is a match\n";
273 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
274 let result = rule.check(&ctx).unwrap();
275 assert_eq!(result.len(), 1);
276 assert_eq!(result[0].line, 2);
277 }
278
279 #[test]
280 fn test_word_boundary_with_punctuation() {
281 let rule = MD061ForbiddenTerms::new(vec!["TODO".to_string()], false);
282 let content = "TODO: colon\nTODO. period\n(TODO) parens\n";
283 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
284 let result = rule.check(&ctx).unwrap();
285 assert_eq!(result.len(), 3);
286 }
287
288 #[test]
289 fn test_skip_fenced_code_block() {
290 let rule = MD061ForbiddenTerms::new(vec!["TODO".to_string()], false);
291 let content = "# Heading\n\n```\nTODO: in code block\n```\n\nTODO: outside\n";
292 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
293 let result = rule.check(&ctx).unwrap();
294 assert_eq!(result.len(), 1);
295 assert_eq!(result[0].line, 7);
296 }
297
298 #[test]
299 fn test_skip_indented_code_block() {
300 let rule = MD061ForbiddenTerms::new(vec!["TODO".to_string()], false);
301 let content = "# Heading\n\n TODO: in indented code\n\nTODO: outside\n";
302 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
303 let result = rule.check(&ctx).unwrap();
304 assert_eq!(result.len(), 1);
305 assert_eq!(result[0].line, 5);
306 }
307
308 #[test]
309 fn test_skip_inline_code() {
310 let rule = MD061ForbiddenTerms::new(vec!["TODO".to_string()], false);
311 let content = "Here is `TODO` in inline code\nTODO: outside inline\n";
312 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
313 let result = rule.check(&ctx).unwrap();
314 assert_eq!(result.len(), 1);
315 assert_eq!(result[0].line, 2);
316 }
317
318 #[test]
319 fn test_skip_frontmatter() {
320 let rule = MD061ForbiddenTerms::new(vec!["TODO".to_string()], false);
321 let content = "---\ntitle: TODO in frontmatter\n---\n\nTODO: outside\n";
322 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
323 let result = rule.check(&ctx).unwrap();
324 assert_eq!(result.len(), 1);
325 assert_eq!(result[0].line, 5);
326 }
327
328 #[test]
329 fn test_multiple_terms_on_same_line() {
330 let rule = MD061ForbiddenTerms::new(vec!["TODO".to_string(), "FIXME".to_string()], false);
331 let content = "TODO: first thing FIXME: second thing\n";
332 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
333 let result = rule.check(&ctx).unwrap();
334 assert_eq!(result.len(), 2);
335 }
336
337 #[test]
338 fn test_term_at_start_of_line() {
339 let rule = MD061ForbiddenTerms::new(vec!["TODO".to_string()], false);
340 let content = "TODO at start\n";
341 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
342 let result = rule.check(&ctx).unwrap();
343 assert_eq!(result.len(), 1);
344 assert_eq!(result[0].column, 1);
345 }
346
347 #[test]
348 fn test_term_at_end_of_line() {
349 let rule = MD061ForbiddenTerms::new(vec!["TODO".to_string()], false);
350 let content = "something TODO\n";
351 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
352 let result = rule.check(&ctx).unwrap();
353 assert_eq!(result.len(), 1);
354 }
355
356 #[test]
357 fn test_custom_terms() {
358 let rule = MD061ForbiddenTerms::new(vec!["HACK".to_string(), "XXX".to_string()], false);
359 let content = "HACK: workaround\nXXX: needs review\nTODO: not configured\n";
360 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
361 let result = rule.check(&ctx).unwrap();
362 assert_eq!(result.len(), 2);
363 }
364
365 #[test]
366 fn test_no_fix_available() {
367 let rule = MD061ForbiddenTerms::new(vec!["TODO".to_string()], false);
368 let content = "TODO: something\n";
369 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
370 let result = rule.check(&ctx).unwrap();
371 assert_eq!(result.len(), 1);
372 assert!(result[0].fix.is_none());
373 }
374
375 #[test]
376 fn test_column_positions() {
377 let rule = MD061ForbiddenTerms::new(vec!["TODO".to_string()], false);
378 let content = " TODO: indented\n";
380 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
381 let result = rule.check(&ctx).unwrap();
382 assert_eq!(result.len(), 1);
383 assert_eq!(result[0].column, 3); assert_eq!(result[0].end_column, 7);
385 }
386
387 #[test]
388 fn test_config_from_toml() {
389 let mut config = crate::config::Config::default();
390 let mut rule_config = crate::config::RuleConfig::default();
391 rule_config.values.insert(
392 "terms".to_string(),
393 toml::Value::Array(vec![toml::Value::String("FIXME".to_string())]),
394 );
395 config.rules.insert("MD061".to_string(), rule_config);
396
397 let rule = MD061ForbiddenTerms::from_config(&config);
398 let content = "FIXME: configured\nTODO: not configured\n";
399 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
400 let result = rule.check(&ctx).unwrap();
401 assert_eq!(result.len(), 1);
402 assert!(result[0].message.contains("forbidden term"));
403 assert!(result[0].message.contains("FIXME"));
404 }
405
406 #[test]
407 fn test_config_from_toml_case_sensitive_by_default() {
408 let mut config = crate::config::Config::default();
411 let mut rule_config = crate::config::RuleConfig::default();
412 rule_config.values.insert(
413 "terms".to_string(),
414 toml::Value::Array(vec![toml::Value::String("TODO".to_string())]),
415 );
416 config.rules.insert("MD061".to_string(), rule_config);
417
418 let rule = MD061ForbiddenTerms::from_config(&config);
419 let content = "todo: lowercase\nTODO: uppercase\nTodo: mixed\n";
420 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
421 let result = rule.check(&ctx).unwrap();
422
423 assert_eq!(result.len(), 1);
425 assert_eq!(result[0].line, 2);
426 }
427
428 #[test]
429 fn test_skip_html_comment() {
430 let rule = MD061ForbiddenTerms::new(vec!["TODO".to_string()], false);
431 let content = "<!-- TODO: in html comment -->\nTODO: outside\n";
432 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
433 let result = rule.check(&ctx).unwrap();
434 assert_eq!(result.len(), 1);
435 assert_eq!(result[0].line, 2);
436 }
437
438 #[test]
439 fn test_skip_double_backtick_inline_code() {
440 let rule = MD061ForbiddenTerms::new(vec!["TODO".to_string()], false);
441 let content = "Here is ``TODO`` in double backticks\nTODO: outside\n";
442 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
443 let result = rule.check(&ctx).unwrap();
444 assert_eq!(result.len(), 1);
445 assert_eq!(result[0].line, 2);
446 }
447
448 #[test]
449 fn test_skip_triple_backtick_inline_code() {
450 let rule = MD061ForbiddenTerms::new(vec!["TODO".to_string()], false);
451 let content = "Here is ```TODO``` in triple backticks\nTODO: outside\n";
452 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
453 let result = rule.check(&ctx).unwrap();
454 assert_eq!(result.len(), 1);
455 assert_eq!(result[0].line, 2);
456 }
457
458 #[test]
459 fn test_inline_code_with_backtick_content() {
460 let rule = MD061ForbiddenTerms::new(vec!["TODO".to_string()], false);
461 let content = "Use `` `TODO` `` to show a backtick\nTODO: outside\n";
463 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
464 let result = rule.check(&ctx).unwrap();
465 assert_eq!(result.len(), 1);
466 assert_eq!(result[0].line, 2);
467 }
468}