Skip to main content

cha_core/plugins/
naming.rs

1use crate::{AnalysisContext, Finding, Location, Patch, Plugin, Severity, SmellCategory, TextEdit};
2
3/// Check naming conventions for functions and classes.
4pub struct NamingAnalyzer {
5    pub min_name_length: usize,
6    pub max_name_length: usize,
7}
8
9impl Default for NamingAnalyzer {
10    fn default() -> Self {
11        Self {
12            min_name_length: 2,
13            max_name_length: 50,
14        }
15    }
16}
17
18impl Plugin for NamingAnalyzer {
19    fn name(&self) -> &str {
20        "naming"
21    }
22
23    fn smells(&self) -> Vec<String> {
24        vec![
25            "naming_convention".into(),
26            "naming_too_short".into(),
27            "naming_too_long".into(),
28        ]
29    }
30
31    fn description(&self) -> &str {
32        "Naming convention violations"
33    }
34
35    fn analyze(&self, ctx: &AnalysisContext) -> Vec<Finding> {
36        let mut findings = Vec::new();
37        self.check_functions(ctx, &mut findings);
38        self.check_classes(ctx, &mut findings);
39        findings
40    }
41
42    fn try_fix(&self, finding: &Finding, ctx: &AnalysisContext) -> Option<Patch> {
43        if finding.smell_name != "naming_convention" {
44            return None;
45        }
46        let name = finding.location.name.as_ref()?;
47        let new_name = to_pascal_case(name);
48        if new_name == *name {
49            return None;
50        }
51        let tree = ctx.tree?;
52        let source = ctx.file.content.as_bytes();
53        let mut edits = Vec::new();
54        collect_identifier_edits(tree.root_node(), source, name, &new_name, &mut edits);
55        if edits.is_empty() {
56            return None;
57        }
58        Some(Patch {
59            file: ctx.file.path.clone(),
60            edits,
61        })
62    }
63}
64
65fn to_pascal_case(name: &str) -> String {
66    let mut chars = name.chars();
67    match chars.next() {
68        None => String::new(),
69        Some(first) => first.to_uppercase().chain(chars).collect(),
70    }
71}
72
73fn collect_identifier_edits(
74    node: tree_sitter::Node,
75    source: &[u8],
76    target: &str,
77    new_text: &str,
78    out: &mut Vec<TextEdit>,
79) {
80    if matches!(
81        node.kind(),
82        "identifier" | "type_identifier" | "field_identifier" | "property_identifier"
83    ) && let Ok(text) = node.utf8_text(source)
84        && text == target
85    {
86        out.push(TextEdit {
87            start_byte: node.start_byte(),
88            end_byte: node.end_byte(),
89            new_text: new_text.to_string(),
90        });
91    }
92    let mut cursor = node.walk();
93    for child in node.children(&mut cursor) {
94        collect_identifier_edits(child, source, target, new_text, out);
95    }
96}
97
98impl NamingAnalyzer {
99    fn check_functions(&self, ctx: &AnalysisContext, findings: &mut Vec<Finding>) {
100        for f in &ctx.model.functions {
101            let check = NameCheck {
102                name: &f.name,
103                kind: "Function",
104                path: &ctx.file.path,
105                start_line: f.start_line,
106                start_col: f.name_col,
107                end_line: f.start_line,
108                end_col: f.name_end_col,
109            };
110            if let Some(finding) = check_name(&check, self.min_name_length, self.max_name_length) {
111                findings.push(finding);
112            }
113        }
114    }
115
116    fn check_classes(&self, ctx: &AnalysisContext, findings: &mut Vec<Finding>) {
117        for c in &ctx.model.classes {
118            if let Some(f) = check_pascal_case(c, &ctx.file.path) {
119                findings.push(f);
120            }
121            let check = NameCheck {
122                name: &c.name,
123                kind: "Class",
124                path: &ctx.file.path,
125                start_line: c.start_line,
126                start_col: c.name_col,
127                end_line: c.start_line,
128                end_col: c.name_end_col,
129            };
130            if let Some(f) = check_name(&check, self.min_name_length, self.max_name_length) {
131                findings.push(f);
132            }
133        }
134    }
135}
136
137/// Check if a class name violates PascalCase convention.
138fn check_pascal_case(c: &crate::ClassInfo, path: &std::path::Path) -> Option<Finding> {
139    if c.name.is_empty() || c.name.chars().next().is_some_and(|ch| ch.is_uppercase()) {
140        return None;
141    }
142    Some(Finding {
143        smell_name: "naming_convention".into(),
144        category: SmellCategory::Bloaters,
145        severity: Severity::Hint,
146        location: Location {
147            path: path.to_path_buf(),
148            start_line: c.start_line,
149            start_col: c.name_col,
150            end_line: c.start_line,
151            end_col: c.name_end_col,
152            name: Some(c.name.clone()),
153        },
154        message: format!("Class `{}` should use PascalCase", c.name),
155        suggested_refactorings: vec!["Rename Method".into()],
156        ..Default::default()
157    })
158}
159
160struct NameCheck<'a> {
161    name: &'a str,
162    kind: &'a str,
163    path: &'a std::path::Path,
164    start_line: usize,
165    start_col: usize,
166    end_line: usize,
167    end_col: usize,
168}
169
170fn check_name(check: &NameCheck, min_len: usize, max_len: usize) -> Option<Finding> {
171    let (smell, severity, qualifier, limit) = if check.name.len() < min_len {
172        ("naming_too_short", Severity::Warning, "short", min_len)
173    } else if check.name.len() > max_len {
174        ("naming_too_long", Severity::Hint, "long", max_len)
175    } else {
176        return None;
177    };
178    let bound_label = if qualifier == "short" { "min" } else { "max" };
179    Some(Finding {
180        smell_name: smell.into(),
181        category: SmellCategory::Bloaters,
182        severity,
183        location: Location {
184            path: check.path.to_path_buf(),
185            start_line: check.start_line,
186            start_col: check.start_col,
187            end_line: check.end_line,
188            end_col: check.end_col,
189            name: Some(check.name.to_string()),
190        },
191        message: format!(
192            "{} `{}` name is too {} ({} chars, {}: {})",
193            check.kind,
194            check.name,
195            qualifier,
196            check.name.len(),
197            bound_label,
198            limit
199        ),
200        suggested_refactorings: vec!["Rename Method".into()],
201        ..Default::default()
202    })
203}