1use crate::{AnalysisContext, Finding, Location, Patch, Plugin, Severity, SmellCategory, TextEdit};
2
3pub 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
137fn 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}