sniff-cli 0.1.2

An exhaustive LLM-backed slop finder for codebases
Documentation
use super::*;
use crate::types::Reference;
use tree_sitter::Node;

#[allow(clippy::too_many_arguments)]
fn push_method(
    methods: &mut Vec<MethodRecord>,
    file_path: &str,
    name: &str,
    start: usize,
    end: usize,
    param_count: usize,
    is_exported: bool,
    source: String,
    refs: Vec<String>,
) {
    methods.push(MethodRecord {
        name: name.to_string(),
        file_path: file_path.to_string(),
        source,
        loc: end.saturating_sub(start) + 1,
        param_count,
        start_line: start + 1,
        end_line: end + 1,
        is_exported,
        language: "kotlin".to_string(),
        nesting_depth: 0,
        references: refs
            .into_iter()
            .map(|name| Reference {
                file_path: file_path.to_string(),
                line: start + 1,
                snippet: name,
            })
            .collect(),
        real_ref_count: 0,
    });
}

fn node_text<'a>(node: Node, source: &'a str) -> Option<&'a str> {
    node.utf8_text(source.as_bytes()).ok()
}

fn name_for_node(node: Node, source: &str) -> Option<String> {
    if let Some(field) = node.child_by_field_name("name")
        && let Some(text) = node_text(field, source)
    {
        let cleaned = text.trim().trim_matches('`');
        if !cleaned.is_empty() {
            return Some(cleaned.to_string());
        }
    }

    let kind = node.kind();
    if matches!(kind, "simple_identifier" | "identifier" | "type_identifier")
        && let Some(text) = node_text(node, source)
    {
        let cleaned = text.trim().trim_matches('`');
        if !cleaned.is_empty() {
            return Some(cleaned.to_string());
        }
    }

    let mut cursor = node.walk();
    for child in node.children(&mut cursor) {
        let child_kind = child.kind();
        if child_kind.contains("annotation") || child_kind.contains("modifier") {
            continue;
        }
        if let Some(name) = name_for_node(child, source) {
            return Some(name);
        }
    }
    None
}

fn count_params(node: Node, source: &str) -> usize {
    for field_name in ["value_parameters", "parameters"] {
        if let Some(params) = node.child_by_field_name(field_name) {
            let mut count = 0usize;
            let mut cursor = params.walk();
            for child in params.children(&mut cursor) {
                if child.kind() == "value_parameter" {
                    count += 1;
                }
            }
            if count > 0 {
                return count;
            }

            if let Some(text) = node_text(params, source) {
                let inner = text
                    .trim()
                    .trim_start_matches('(')
                    .trim_end_matches(')')
                    .trim();
                if inner.is_empty() {
                    return 0;
                }
                return inner.split(',').filter(|p| !p.trim().is_empty()).count();
            }
        }
    }
    0
}

fn collect_functions(
    node: Node,
    source: &str,
    file_path: &str,
    methods: &mut Vec<MethodRecord>,
    owner_stack: &mut Vec<String>,
) {
    match node.kind() {
        "class_declaration"
        | "object_declaration"
        | "interface_declaration"
        | "enum_class_declaration"
        | "fun_interface_declaration" => {
            if let Some(owner) = name_for_node(node, source) {
                owner_stack.push(owner);
                let mut cursor = node.walk();
                for child in node.children(&mut cursor) {
                    collect_functions(child, source, file_path, methods, owner_stack);
                }
                owner_stack.pop();
                return;
            }
        }
        "function_declaration" => {
            let Some(name) = name_for_node(node, source) else {
                return;
            };
            if name == "Composable" {
                let mut fallback_cursor = node.walk();
                for child in node.children(&mut fallback_cursor) {
                    let child_kind = child.kind();
                    if child_kind.contains("annotation") || child_kind.contains("modifier") {
                        continue;
                    }
                    if let Some(text) = node_text(child, source) {
                        let cleaned = text.trim().trim_matches('`');
                        if !cleaned.is_empty()
                            && cleaned != "Composable"
                            && (child.kind().contains("identifier")
                                || child.kind().contains("simple"))
                        {
                            let start = node.start_position().row;
                            let end = node.end_position().row;
                            let source_text = node_text(node, source).unwrap_or("").to_string();
                            let refs = source_text
                                .lines()
                                .flat_map(super::refs::collect_refs)
                                .filter(|r| r != cleaned)
                                .collect::<Vec<_>>();
                            let is_exported = !source
                                .lines()
                                .nth(start)
                                .map(|line| line.contains("private "))
                                .unwrap_or(false);
                            push_method(
                                methods,
                                file_path,
                                cleaned,
                                start,
                                end,
                                count_params(node, source),
                                is_exported,
                                source_text,
                                refs,
                            );
                            return;
                        }
                    }
                }
            }
            let start = node.start_position().row;
            let end = node.end_position().row;
            let source_text = node_text(node, source).unwrap_or("").to_string();
            let refs = source_text
                .lines()
                .flat_map(super::refs::collect_refs)
                .filter(|r| r != &name)
                .collect::<Vec<_>>();
            let is_exported = !source
                .lines()
                .nth(start)
                .map(|line| line.contains("private "))
                .unwrap_or(false);
            push_method(
                methods,
                file_path,
                &name,
                start,
                end,
                count_params(node, source),
                is_exported,
                source_text,
                refs,
            );
        }
        _ => {}
    }

    let mut cursor = node.walk();
    for child in node.children(&mut cursor) {
        collect_functions(child, source, file_path, methods, owner_stack);
    }
}

pub(crate) fn extract_methods(
    root: Node,
    source_bytes: &[u8],
    adapter: &LanguageAdapter,
    file_path: &str,
) -> Vec<MethodRecord> {
    let source = String::from_utf8_lossy(source_bytes);
    let mut methods = Vec::new();
    let mut owner_stack = Vec::new();
    collect_functions(root, &source, file_path, &mut methods, &mut owner_stack);
    let _ = adapter;
    methods
}