Skip to main content

cha_core/plugins/
todo_tracker.rs

1use crate::{AnalysisContext, Finding, Location, Plugin, Severity, SmellCategory};
2
3// cha:ignore todo_comment
4/// Detect leftover task comments (todo/fixme/hack/xxx) in source code.
5///
6/// Severity levels: H/X tags → Warning, F/T tags → Hint.
7pub struct TodoTrackerAnalyzer;
8
9impl Plugin for TodoTrackerAnalyzer {
10    fn name(&self) -> &str {
11        "todo_tracker"
12    }
13
14    fn smells(&self) -> Vec<String> {
15        vec!["todo_comment".into()]
16    }
17
18    fn description(&self) -> &str {
19        "Leftover TODO/FIXME/HACK/XXX comments"
20    }
21
22    fn analyze(&self, ctx: &AnalysisContext) -> Vec<Finding> {
23        ctx.model
24            .comments
25            .iter()
26            .filter_map(|c| check_comment(c, ctx))
27            .collect()
28    }
29}
30
31fn check_comment(c: &crate::CommentInfo, ctx: &AnalysisContext) -> Option<Finding> {
32    let upper = c.text.to_uppercase();
33    let (tag, severity) = if has_tag(&upper, "HACK") {
34        ("HACK", Severity::Warning)
35    } else if has_tag(&upper, "XXX") {
36        ("XXX", Severity::Warning)
37    } else if has_tag(&upper, "FIXME") {
38        ("FIXME", Severity::Hint)
39    } else if has_tag(&upper, "TODO") {
40        ("TODO", Severity::Hint)
41    } else {
42        return None;
43    };
44    let col = upper.find(tag).unwrap_or(0);
45    Some(Finding {
46        smell_name: "todo_comment".into(),
47        category: SmellCategory::Dispensables,
48        severity,
49        location: Location {
50            path: ctx.file.path.clone(),
51            start_line: c.line,
52            start_col: col,
53            end_line: c.line,
54            end_col: col + tag.len(),
55            name: None,
56        },
57        message: format!(
58            "{tag}: {}",
59            c.text.trim_start_matches(['/', '#', '*', ' ', '-'])
60        ),
61        suggested_refactorings: vec!["Resolve or create a tracking issue".into()],
62        ..Default::default()
63    })
64}
65
66/// Match tag as a word boundary (e.g. "TAG:" or "TAG " but not "TAGLIST")
67fn has_tag(line: &str, tag: &str) -> bool {
68    if let Some(pos) = line.find(tag) {
69        let after = pos + tag.len();
70        after >= line.len() || !line.as_bytes()[after].is_ascii_alphabetic()
71    } else {
72        false
73    }
74}