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};
5
6mod md061_config;
7pub(super) use md061_config::MD061Config;
8
9/// Rule MD061: Forbidden terms
10///
11/// See [docs/md061.md](../../docs/md061.md) for full documentation, configuration, and examples.
12
13#[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        // Build alternation pattern from terms, escaping regex metacharacters
37        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    /// Check if match is at a word boundary
47    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 name(&self) -> &'static str {
72        "MD061"
73    }
74
75    fn description(&self) -> &'static str {
76        "Forbidden terms"
77    }
78
79    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
80        // Early return if no terms configured
81        let Some(pattern) = &self.pattern else {
82            return Ok(Vec::new());
83        };
84
85        let mut warnings = Vec::new();
86
87        // Use filtered_lines to skip frontmatter, code blocks, HTML comments, and Obsidian comments
88        for line in ctx
89            .filtered_lines()
90            .skip_front_matter()
91            .skip_code_blocks()
92            .skip_html_comments()
93            .skip_jsx_expressions()
94            .skip_mdx_comments()
95            .skip_obsidian_comments()
96        {
97            let content = line.content;
98
99            // Find all matches in this line
100            for mat in pattern.find_iter(content) {
101                // Skip if inside inline code (col is 1-indexed)
102                if ctx.is_in_code_span(line.line_num, mat.start() + 1) {
103                    continue;
104                }
105
106                // Check word boundaries
107                if !Self::is_word_boundary(content, mat.start(), mat.end()) {
108                    continue;
109                }
110
111                let matched_term = &content[mat.start()..mat.end()];
112                let display_term = if self.config.case_sensitive {
113                    matched_term.to_string()
114                } else {
115                    matched_term.to_uppercase()
116                };
117
118                warnings.push(LintWarning {
119                    rule_name: Some(self.name().to_string()),
120                    severity: Severity::Warning,
121                    message: format!("Found forbidden term '{display_term}'"),
122                    line: line.line_num,
123                    column: mat.start() + 1,
124                    end_line: line.line_num,
125                    end_column: mat.end() + 1,
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_case_sensitive_mode() {
212        let rule = MD061ForbiddenTerms::new(vec!["TODO".to_string()], true);
213        let content = "todo: lowercase\nTODO: uppercase\nTodo: mixed\n";
214        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
215        let result = rule.check(&ctx).unwrap();
216        assert_eq!(result.len(), 1);
217        assert_eq!(result[0].line, 2);
218    }
219
220    #[test]
221    fn test_word_boundary_no_false_positive() {
222        let rule = MD061ForbiddenTerms::new(vec!["TODO".to_string()], false);
223        let content = "TODOMORROW is not a match\nTODO is a match\n";
224        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
225        let result = rule.check(&ctx).unwrap();
226        assert_eq!(result.len(), 1);
227        assert_eq!(result[0].line, 2);
228    }
229
230    #[test]
231    fn test_word_boundary_with_punctuation() {
232        let rule = MD061ForbiddenTerms::new(vec!["TODO".to_string()], false);
233        let content = "TODO: colon\nTODO. period\n(TODO) parens\n";
234        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
235        let result = rule.check(&ctx).unwrap();
236        assert_eq!(result.len(), 3);
237    }
238
239    #[test]
240    fn test_skip_fenced_code_block() {
241        let rule = MD061ForbiddenTerms::new(vec!["TODO".to_string()], false);
242        let content = "# Heading\n\n```\nTODO: in code block\n```\n\nTODO: outside\n";
243        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
244        let result = rule.check(&ctx).unwrap();
245        assert_eq!(result.len(), 1);
246        assert_eq!(result[0].line, 7);
247    }
248
249    #[test]
250    fn test_skip_indented_code_block() {
251        let rule = MD061ForbiddenTerms::new(vec!["TODO".to_string()], false);
252        let content = "# Heading\n\n    TODO: in indented code\n\nTODO: outside\n";
253        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
254        let result = rule.check(&ctx).unwrap();
255        assert_eq!(result.len(), 1);
256        assert_eq!(result[0].line, 5);
257    }
258
259    #[test]
260    fn test_skip_inline_code() {
261        let rule = MD061ForbiddenTerms::new(vec!["TODO".to_string()], false);
262        let content = "Here is `TODO` in inline code\nTODO: outside inline\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_skip_frontmatter() {
271        let rule = MD061ForbiddenTerms::new(vec!["TODO".to_string()], false);
272        let content = "---\ntitle: TODO in frontmatter\n---\n\nTODO: outside\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, 5);
277    }
278
279    #[test]
280    fn test_multiple_terms_on_same_line() {
281        let rule = MD061ForbiddenTerms::new(vec!["TODO".to_string(), "FIXME".to_string()], false);
282        let content = "TODO: first thing FIXME: second thing\n";
283        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
284        let result = rule.check(&ctx).unwrap();
285        assert_eq!(result.len(), 2);
286    }
287
288    #[test]
289    fn test_term_at_start_of_line() {
290        let rule = MD061ForbiddenTerms::new(vec!["TODO".to_string()], false);
291        let content = "TODO at start\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].column, 1);
296    }
297
298    #[test]
299    fn test_term_at_end_of_line() {
300        let rule = MD061ForbiddenTerms::new(vec!["TODO".to_string()], false);
301        let content = "something TODO\n";
302        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
303        let result = rule.check(&ctx).unwrap();
304        assert_eq!(result.len(), 1);
305    }
306
307    #[test]
308    fn test_custom_terms() {
309        let rule = MD061ForbiddenTerms::new(vec!["HACK".to_string(), "XXX".to_string()], false);
310        let content = "HACK: workaround\nXXX: needs review\nTODO: not configured\n";
311        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
312        let result = rule.check(&ctx).unwrap();
313        assert_eq!(result.len(), 2);
314    }
315
316    #[test]
317    fn test_no_fix_available() {
318        let rule = MD061ForbiddenTerms::new(vec!["TODO".to_string()], false);
319        let content = "TODO: something\n";
320        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
321        let result = rule.check(&ctx).unwrap();
322        assert_eq!(result.len(), 1);
323        assert!(result[0].fix.is_none());
324    }
325
326    #[test]
327    fn test_column_positions() {
328        let rule = MD061ForbiddenTerms::new(vec!["TODO".to_string()], false);
329        // Use 2 spaces, not 4 (4 spaces creates a code block)
330        let content = "  TODO: indented\n";
331        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
332        let result = rule.check(&ctx).unwrap();
333        assert_eq!(result.len(), 1);
334        assert_eq!(result[0].column, 3); // 1-based column, TODO starts at col 3
335        assert_eq!(result[0].end_column, 7);
336    }
337
338    #[test]
339    fn test_config_from_toml() {
340        let mut config = crate::config::Config::default();
341        let mut rule_config = crate::config::RuleConfig::default();
342        rule_config.values.insert(
343            "terms".to_string(),
344            toml::Value::Array(vec![toml::Value::String("FIXME".to_string())]),
345        );
346        config.rules.insert("MD061".to_string(), rule_config);
347
348        let rule = MD061ForbiddenTerms::from_config(&config);
349        let content = "FIXME: configured\nTODO: not configured\n";
350        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
351        let result = rule.check(&ctx).unwrap();
352        assert_eq!(result.len(), 1);
353        assert!(result[0].message.contains("forbidden term"));
354        assert!(result[0].message.contains("FIXME"));
355    }
356
357    #[test]
358    fn test_config_from_toml_case_sensitive_by_default() {
359        // Simulates user config: [MD061] terms = ["TODO"]
360        // Without explicitly setting case_sensitive, should default to true
361        let mut config = crate::config::Config::default();
362        let mut rule_config = crate::config::RuleConfig::default();
363        rule_config.values.insert(
364            "terms".to_string(),
365            toml::Value::Array(vec![toml::Value::String("TODO".to_string())]),
366        );
367        config.rules.insert("MD061".to_string(), rule_config);
368
369        let rule = MD061ForbiddenTerms::from_config(&config);
370        let content = "todo: lowercase\nTODO: uppercase\nTodo: mixed\n";
371        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
372        let result = rule.check(&ctx).unwrap();
373
374        // Should only match "TODO" (uppercase), not "todo" or "Todo"
375        assert_eq!(result.len(), 1);
376        assert_eq!(result[0].line, 2);
377    }
378
379    #[test]
380    fn test_skip_html_comment() {
381        let rule = MD061ForbiddenTerms::new(vec!["TODO".to_string()], false);
382        let content = "<!-- TODO: in html comment -->\nTODO: outside\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].line, 2);
387    }
388
389    #[test]
390    fn test_skip_double_backtick_inline_code() {
391        let rule = MD061ForbiddenTerms::new(vec!["TODO".to_string()], false);
392        let content = "Here is ``TODO`` in double backticks\nTODO: outside\n";
393        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
394        let result = rule.check(&ctx).unwrap();
395        assert_eq!(result.len(), 1);
396        assert_eq!(result[0].line, 2);
397    }
398
399    #[test]
400    fn test_skip_triple_backtick_inline_code() {
401        let rule = MD061ForbiddenTerms::new(vec!["TODO".to_string()], false);
402        let content = "Here is ```TODO``` in triple backticks\nTODO: outside\n";
403        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
404        let result = rule.check(&ctx).unwrap();
405        assert_eq!(result.len(), 1);
406        assert_eq!(result[0].line, 2);
407    }
408
409    #[test]
410    fn test_inline_code_with_backtick_content() {
411        let rule = MD061ForbiddenTerms::new(vec!["TODO".to_string()], false);
412        // Content with a backtick inside: `` `TODO` ``
413        let content = "Use `` `TODO` `` to show a backtick\nTODO: outside\n";
414        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
415        let result = rule.check(&ctx).unwrap();
416        assert_eq!(result.len(), 1);
417        assert_eq!(result[0].line, 2);
418    }
419}