lean_ctx/core/code_health/
naming.rs1use serde::Serialize;
10
11#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
13pub struct NamingFinding {
14 pub name: String,
15 pub line: usize,
17 pub message: String,
18}
19
20pub fn naming_findings(source: &str, extension: &str) -> Option<Vec<NamingFinding>> {
23 #[cfg(feature = "tree-sitter")]
24 {
25 let mut out: Vec<NamingFinding> = Vec::new();
26 super::astutil::for_each_function(source, extension, |fn_node, name, _src| {
27 if let Some(message) = cryptic_reason(name) {
28 let line = fn_node.start_position().row.saturating_add(1);
29 out.push(NamingFinding {
30 name: name.to_string(),
31 line,
32 message,
33 });
34 }
35 })?;
36 out.sort_by(|a, b| a.line.cmp(&b.line).then_with(|| a.name.cmp(&b.name)));
37 out.dedup();
38 Some(out)
39 }
40 #[cfg(not(feature = "tree-sitter"))]
41 {
42 let _ = (source, extension);
43 None
44 }
45}
46
47pub fn cryptic_reason(name: &str) -> Option<String> {
50 let core = name.trim_start_matches('_');
51 if core.is_empty() || core == "<anonymous>" {
52 return None;
53 }
54 if is_allowed(core) {
55 return None;
56 }
57 let len = core.chars().count();
58 if len <= 2 {
59 return Some(format!("name `{name}` is too short to convey intent"));
60 }
61 if !has_vowel(core) && !is_known_acronym(core) {
62 return Some(format!(
63 "name `{name}` has no vowels — likely a cryptic abbreviation"
64 ));
65 }
66 None
67}
68
69fn is_allowed(core: &str) -> bool {
71 matches!(
72 core.to_ascii_lowercase().as_str(),
73 "id" | "ok" | "io" | "db" | "ui" | "os" | "vm" | "fn" | "go" | "rx" | "tx" | "fd" | "ip"
74 )
75}
76
77fn is_known_acronym(core: &str) -> bool {
79 matches!(
80 core.to_ascii_lowercase().as_str(),
81 "db" | "js"
82 | "ts"
83 | "css"
84 | "html"
85 | "http"
86 | "https"
87 | "url"
88 | "uri"
89 | "sql"
90 | "xml"
91 | "json"
92 | "jwt"
93 | "rpc"
94 | "grpc"
95 | "tcp"
96 | "udp"
97 | "ip"
98 | "dns"
99 | "fs"
100 | "os"
101 | "vm"
102 | "csv"
103 | "pdf"
104 | "png"
105 | "jpg"
106 | "svg"
107 | "md5"
108 | "sha"
109 | "crc"
110 )
111}
112
113fn has_vowel(s: &str) -> bool {
114 s.chars()
115 .any(|c| matches!(c.to_ascii_lowercase(), 'a' | 'e' | 'i' | 'o' | 'u' | 'y'))
116}
117
118#[cfg(test)]
119mod tests {
120 use super::*;
121
122 #[test]
123 fn flags_vowelless_abbreviation() {
124 assert!(cryptic_reason("_xfm_q2").is_some());
125 assert!(cryptic_reason("qstr").is_some());
126 }
127
128 #[test]
129 fn flags_too_short() {
130 assert!(cryptic_reason("zq").is_some());
131 assert!(cryptic_reason("x").is_some());
132 }
133
134 #[test]
135 fn accepts_descriptive_names() {
136 assert!(cryptic_reason("normalize_query").is_none());
137 assert!(cryptic_reason("parse").is_none());
138 assert!(cryptic_reason("handleRequest").is_none());
139 }
140
141 #[test]
142 fn accepts_known_short_and_acronyms() {
143 assert!(cryptic_reason("id").is_none());
144 assert!(cryptic_reason("db").is_none());
145 assert!(cryptic_reason("to_json").is_none());
146 assert!(cryptic_reason("http").is_none());
147 }
148
149 #[cfg(feature = "tree-sitter")]
150 #[test]
151 fn finds_cryptic_function_in_source() {
152 let src = "fn _xfm_q2(a: i32) -> i32 { a }\nfn normalize_query(b: i32) -> i32 { b }\n";
153 let findings = naming_findings(src, "rs").unwrap();
154 assert_eq!(findings.len(), 1);
155 assert_eq!(findings[0].name, "_xfm_q2");
156 }
157
158 #[cfg(feature = "tree-sitter")]
159 #[test]
160 fn deterministic_across_runs() {
161 let src = "fn zq() {}\nfn ab() {}\n";
162 assert_eq!(naming_findings(src, "rs"), naming_findings(src, "rs"));
163 }
164}