sniff-cli 0.1.0

An exhaustive LLM-backed slop finder for codebases
Documentation
fn normalize_name(name: &str) -> String {
    let mut result = String::new();
    let chars: Vec<char> = name.chars().collect();
    for i in 0..chars.len() {
        let c = chars[i];
        if i > 0 {
            let prev = chars[i - 1];
            let is_next_lower = i + 1 < chars.len() && chars[i + 1].is_lowercase();
            let is_prev_lower_or_digit = prev.is_lowercase() || prev.is_ascii_digit();

            if (c.is_uppercase() && is_next_lower && prev != '_')
                || (is_prev_lower_or_digit && c.is_uppercase())
            {
                result.push('_');
            }
        }
        result.push(c.to_ascii_lowercase());
    }
    result
}

fn match_pattern(name: &str, pattern: &str) -> bool {
    if let Some(prefix) = pattern.strip_suffix('*') {
        return name.starts_with(prefix);
    }
    if let Some(suffix) = pattern.strip_prefix('*') {
        return name.ends_with(suffix);
    }
    name == pattern
}

fn token_count(name: &str) -> usize {
    name.split(|ch: char| !ch.is_ascii_alphanumeric())
        .filter(|token| !token.is_empty())
        .count()
}

pub(super) fn is_generic(
    method_name: &str,
    config: &crate::config::ResolvedConfig,
    adapter: &crate::language_adapter::LanguageAdapter,
) -> bool {
    let norm_name = normalize_name(method_name);

    for pat in &adapter.allowed_names {
        if match_pattern(&norm_name, &normalize_name(pat)) {
            return false;
        }
    }

    let tokens = token_count(&norm_name);

    for pat in &config.generic_names {
        if match_pattern(&norm_name, &normalize_name(pat)) && tokens <= 2 {
            return true;
        }
    }

    for pat in &adapter.generic_names {
        if match_pattern(&norm_name, &normalize_name(pat)) && tokens <= 2 {
            return true;
        }
    }

    false
}

#[cfg(test)]
mod tests {
    use super::is_generic;
    use crate::config::ResolvedConfig;
    use crate::language_adapter::{ExportDetection, LanguageAdapter};

    fn adapter() -> LanguageAdapter {
        LanguageAdapter {
            name: "python".to_string(),
            grammar_package: "tree-sitter-python".to_string(),
            extensions: vec![".py".to_string()],
            function_node_types: vec![],
            excluded_parent_types: vec![],
            name_field: "name".to_string(),
            params_field: "parameters".to_string(),
            param_node_types: vec![],
            nesting_node_types: vec![],
            export_detection: ExportDetection::Convention,
            generic_names: vec![
                "handle*".to_string(),
                "process*".to_string(),
                "get*".to_string(),
            ],
            allowed_names: vec![],
        }
    }

    #[test]
    fn long_descriptive_names_are_not_marked_generic() {
        let config = ResolvedConfig::default();
        let adapter = adapter();

        assert!(!is_generic("process_release_command", &config, &adapter));
        assert!(!is_generic("handle_github_webhook", &config, &adapter));
        assert!(!is_generic("get_pull_request", &config, &adapter));
    }

    #[test]
    fn short_greedy_names_are_still_generic() {
        let config = ResolvedConfig::default();
        let adapter = adapter();

        assert!(is_generic("get", &config, &adapter));
        assert!(is_generic("data", &config, &adapter));
        assert!(is_generic("process_data", &config, &adapter));
    }
}