Skip to main content

rumdl_lib/rules/
md061_forbidden_terms.rs

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/// Rule MD061: Forbidden terms
11///
12/// See [docs/md061.md](../../docs/md061.md) for full documentation, configuration, and examples.
13
14#[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        // Build alternation pattern from terms, escaping regex metacharacters
38        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    /// Check if match is at a word boundary
48    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        // Early return if no terms configured
82        let Some(pattern) = &self.pattern else {
83            return Ok(Vec::new());
84        };
85
86        let mut warnings = Vec::new();
87
88        // Use filtered_lines to skip frontmatter, code blocks, HTML comments, and Obsidian comments
89        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            // Find all matches in this line
101            for mat in pattern.find_iter(content) {
102                // Skip if inside inline code (col is a 1-indexed character column)
103                if ctx.is_in_code_span(line.line_num, byte_to_char_count(content, mat.start())) {
104                    continue;
105                }
106
107                // Check word boundaries
108                if !Self::is_word_boundary(content, mat.start(), mat.end()) {
109                    continue;
110                }
111
112                // Quote the term as the document writes it. The reported range covers
113                // exactly these bytes, and under case-insensitive matching the text can
114                // differ from the configured spelling, so any case-folded form would
115                // name something that appears in neither the document nor the config.
116                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, // No auto-fix for warning comments
127                });
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        // Skip if no terms configured
152        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        // Default is case-sensitive, so only exact match "TODO" is found
189        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); // Only "TODO" on line 2 matches
199    }
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        // Case-insensitive matching quotes the document, not a case-folded form:
213        // "DELVE" appears in neither the document nor the configured terms.
214        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        // The quoted term and the reported range name the same text, including on a
245        // line whose match is preceded by multi-byte characters.
246        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        // Use 2 spaces, not 4 (4 spaces creates a code block)
379        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); // 1-based column, TODO starts at col 3
384        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        // Simulates user config: [MD061] terms = ["TODO"]
409        // Without explicitly setting case_sensitive, should default to true
410        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        // Should only match "TODO" (uppercase), not "todo" or "Todo"
424        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        // Content with a backtick inside: `` `TODO` ``
462        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}