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#[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 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        // Early return if no terms configured
85        let Some(pattern) = &self.pattern else {
86            return Ok(Vec::new());
87        };
88
89        let mut warnings = Vec::new();
90
91        // Use filtered_lines to skip frontmatter, code blocks, HTML comments, and Obsidian comments
92        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            // Find all matches in this line
104            for mat in pattern.find_iter(content) {
105                // Skip if inside inline code (col is a 1-indexed character column)
106                if ctx.is_in_code_span(line.line_num, byte_to_char_count(content, mat.start())) {
107                    continue;
108                }
109
110                // Check word boundaries
111                if !Self::is_word_boundary(content, mat.start(), mat.end()) {
112                    continue;
113                }
114
115                // Quote the term as the document writes it. The reported range covers
116                // exactly these bytes, and under case-insensitive matching the text can
117                // differ from the configured spelling, so any case-folded form would
118                // name something that appears in neither the document nor the config.
119                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, // No auto-fix for warning comments
130                });
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        // Skip if no terms configured
155        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        // Default is case-sensitive, so only exact match "TODO" is found
192        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); // Only "TODO" on line 2 matches
202    }
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        // Case-insensitive matching quotes the document, not a case-folded form:
216        // "DELVE" appears in neither the document nor the configured terms.
217        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        // The quoted term and the reported range name the same text, including on a
248        // line whose match is preceded by multi-byte characters.
249        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        // Use 2 spaces, not 4 (4 spaces creates a code block)
382        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); // 1-based column, TODO starts at col 3
387        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        // Simulates user config: [MD061] terms = ["TODO"]
412        // Without explicitly setting case_sensitive, should default to true
413        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        // Should only match "TODO" (uppercase), not "todo" or "Todo"
427        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        // Content with a backtick inside: `` `TODO` ``
465        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}