use serde::Serialize;
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct NamingFinding {
pub name: String,
pub line: usize,
pub message: String,
}
pub fn naming_findings(source: &str, extension: &str) -> Option<Vec<NamingFinding>> {
#[cfg(feature = "tree-sitter")]
{
let mut out: Vec<NamingFinding> = Vec::new();
super::astutil::for_each_function(source, extension, |fn_node, name, _src| {
if let Some(message) = cryptic_reason(name) {
let line = fn_node.start_position().row.saturating_add(1);
out.push(NamingFinding {
name: name.to_string(),
line,
message,
});
}
})?;
out.sort_by(|a, b| a.line.cmp(&b.line).then_with(|| a.name.cmp(&b.name)));
out.dedup();
Some(out)
}
#[cfg(not(feature = "tree-sitter"))]
{
let _ = (source, extension);
None
}
}
pub fn cryptic_reason(name: &str) -> Option<String> {
let core = name.trim_start_matches('_');
if core.is_empty() || core == "<anonymous>" {
return None;
}
if is_allowed(core) {
return None;
}
let len = core.chars().count();
if len <= 2 {
return Some(format!("name `{name}` is too short to convey intent"));
}
if !has_vowel(core) && !is_known_acronym(core) {
return Some(format!(
"name `{name}` has no vowels — likely a cryptic abbreviation"
));
}
None
}
fn is_allowed(core: &str) -> bool {
matches!(
core.to_ascii_lowercase().as_str(),
"id" | "ok" | "io" | "db" | "ui" | "os" | "vm" | "fn" | "go" | "rx" | "tx" | "fd" | "ip"
)
}
fn is_known_acronym(core: &str) -> bool {
matches!(
core.to_ascii_lowercase().as_str(),
"db" | "js"
| "ts"
| "css"
| "html"
| "http"
| "https"
| "url"
| "uri"
| "sql"
| "xml"
| "json"
| "jwt"
| "rpc"
| "grpc"
| "tcp"
| "udp"
| "ip"
| "dns"
| "fs"
| "os"
| "vm"
| "csv"
| "pdf"
| "png"
| "jpg"
| "svg"
| "md5"
| "sha"
| "crc"
)
}
fn has_vowel(s: &str) -> bool {
s.chars()
.any(|c| matches!(c.to_ascii_lowercase(), 'a' | 'e' | 'i' | 'o' | 'u' | 'y'))
}
#[cfg(test)]
pub mod tests {
use super::*;
#[test]
fn flags_vowelless_abbreviation() {
assert!(cryptic_reason("_xfm_q2").is_some());
assert!(cryptic_reason("qstr").is_some());
}
#[test]
fn flags_too_short() {
assert!(cryptic_reason("zq").is_some());
assert!(cryptic_reason("x").is_some());
}
#[test]
fn accepts_descriptive_names() {
assert!(cryptic_reason("normalize_query").is_none());
assert!(cryptic_reason("parse").is_none());
assert!(cryptic_reason("handleRequest").is_none());
}
#[test]
fn accepts_known_short_and_acronyms() {
assert!(cryptic_reason("id").is_none());
assert!(cryptic_reason("db").is_none());
assert!(cryptic_reason("to_json").is_none());
assert!(cryptic_reason("http").is_none());
}
#[cfg(feature = "tree-sitter")]
#[test]
fn finds_cryptic_function_in_source() {
let src = "fn _xfm_q2(a: i32) -> i32 { a }\nfn normalize_query(b: i32) -> i32 { b }\n";
let findings = naming_findings(src, "rs").unwrap();
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].name, "_xfm_q2");
}
#[cfg(feature = "tree-sitter")]
#[test]
fn deterministic_across_runs() {
let src = "fn zq() {}\nfn ab() {}\n";
assert_eq!(naming_findings(src, "rs"), naming_findings(src, "rs"));
}
}