deslop 0.2.0

A static analyzer that spots low-context and AI-assisted code patterns across naming, concurrency, security, performance, and test quality.
Documentation
use tree_sitter::Node;

use crate::analysis::{CallSite, NamedLiteral, StructTag, TestFunctionSummary};

use super::symbols::{
    collect_expression_nodes, collect_identifiers, find_var_name_node, find_var_value_node,
    is_identifier_name, split_assignment,
};

pub(crate) fn collect_pkg_strings(root: Node<'_>, source: &str) -> Vec<NamedLiteral> {
    let mut literals = Vec::new();
    visit_pkg_strings(root, source, &mut literals);
    literals.sort_by(|left, right| left.line.cmp(&right.line).then(left.name.cmp(&right.name)));
    literals
}

fn visit_pkg_strings(node: Node<'_>, source: &str, literals: &mut Vec<NamedLiteral>) {
    if matches!(node.kind(), "var_spec" | "const_spec") && is_package_scope(node) {
        literals.extend(extract_named_strings(node, source));
    }

    let mut cursor = node.walk();
    for child in node.named_children(&mut cursor) {
        visit_pkg_strings(child, source, literals);
    }
}

pub(crate) fn collect_local_strings(body_node: Node<'_>, source: &str) -> Vec<NamedLiteral> {
    let mut literals = Vec::new();
    visit_local_strings(body_node, source, &mut literals);
    literals.sort_by(|left, right| left.line.cmp(&right.line).then(left.name.cmp(&right.name)));
    literals
}

fn visit_local_strings(node: Node<'_>, source: &str, literals: &mut Vec<NamedLiteral>) {
    if matches!(
        node.kind(),
        "var_spec" | "const_spec" | "assignment_statement" | "short_var_declaration"
    ) {
        literals.extend(extract_named_strings(node, source));
    }

    let mut cursor = node.walk();
    for child in node.named_children(&mut cursor) {
        visit_local_strings(child, source, literals);
    }
}

fn extract_named_strings(node: Node<'_>, source: &str) -> Vec<NamedLiteral> {
    let Some(name_node) = find_var_name_node(node) else {
        return fallback_named_string(node, source).into_iter().collect();
    };
    let Some(value_node) = find_var_value_node(node) else {
        return fallback_named_string(node, source).into_iter().collect();
    };

    let names = collect_identifiers(name_node, source);
    let values = collect_expression_nodes(value_node);

    let literals = names
        .into_iter()
        .enumerate()
        .filter_map(|(index, (name, line))| {
            let value_node = values.get(index)?;
            let value = extract_string(*value_node, source)?;
            Some(NamedLiteral { line, name, value })
        })
        .collect::<Vec<_>>();

    if literals.is_empty() {
        fallback_named_string(node, source).into_iter().collect()
    } else {
        literals
    }
}

fn extract_string(node: Node<'_>, source: &str) -> Option<String> {
    match node.kind() {
        "interpreted_string_literal" | "raw_string_literal" => source
            .get(node.byte_range())
            .map(|literal| literal.trim_matches('"').trim_matches('`').to_string()),
        _ => None,
    }
}

fn fallback_named_string(node: Node<'_>, source: &str) -> Option<NamedLiteral> {
    let text = source.get(node.byte_range())?;
    let (left, right) = split_assignment(text)?;
    let name = left.trim().split(',').next()?.trim();
    if !is_identifier_name(name) {
        return None;
    }

    let value = right.trim();
    let value = value
        .strip_prefix('"')
        .and_then(|trimmed| trimmed.strip_suffix('"'))
        .or_else(|| {
            value
                .strip_prefix('`')
                .and_then(|trimmed| trimmed.strip_suffix('`'))
        })?;

    Some(NamedLiteral {
        line: node.start_position().row + 1,
        name: name.to_string(),
        value: value.to_string(),
    })
}

pub(crate) fn collect_struct_tags(root: Node<'_>, source: &str) -> Vec<StructTag> {
    let mut tags = Vec::new();
    visit_struct_tags(root, source, &mut tags);
    tags.sort_by(|left, right| {
        left.line
            .cmp(&right.line)
            .then(left.field_name.cmp(&right.field_name))
    });
    tags
}

fn visit_struct_tags(node: Node<'_>, source: &str, tags: &mut Vec<StructTag>) {
    if node.kind() == "type_spec" {
        tags.extend(extract_struct_tags(node, source));
    }

    let mut cursor = node.walk();
    for child in node.named_children(&mut cursor) {
        visit_struct_tags(child, source, tags);
    }
}

fn extract_struct_tags(node: Node<'_>, source: &str) -> Vec<StructTag> {
    let Some(name_node) = node.child_by_field_name("name") else {
        return Vec::new();
    };
    let Some(type_node) = node.child_by_field_name("type") else {
        return Vec::new();
    };
    if type_node.kind() != "struct_type" {
        return Vec::new();
    }

    let struct_name = source.get(name_node.byte_range()).unwrap_or("").to_string();
    let Some(struct_text) = source.get(type_node.byte_range()) else {
        return Vec::new();
    };

    let mut tags = Vec::new();
    let base_line = type_node.start_position().row + 1;

    for (offset, line) in struct_text.split('\n').enumerate() {
        let trimmed = line.trim();
        if trimmed.is_empty() || trimmed == "struct{" || trimmed == "struct {" || trimmed == "}" {
            continue;
        }
        let Some((_, rest)) = trimmed.split_once('`') else {
            continue;
        };
        let Some((raw_tag, _)) = rest.split_once('`') else {
            continue;
        };

        let field_name = trimmed
            .split_whitespace()
            .next()
            .unwrap_or("")
            .trim_end_matches(',')
            .to_string();
        if field_name.is_empty() {
            continue;
        }

        tags.push(StructTag {
            line: base_line + offset,
            struct_name: struct_name.to_string(),
            field_name,
            raw_tag: raw_tag.to_string(),
        });
    }

    tags
}

pub(crate) fn build_test_summary(
    function_name: &str,
    body_node: Node<'_>,
    source: &str,
    calls: &[CallSite],
    is_test_file: bool,
) -> Option<TestFunctionSummary> {
    if !is_test_file || !function_name.starts_with("Test") {
        return None;
    }

    let body_text = source.get(body_node.byte_range()).unwrap_or("");
    let assertion_like_calls = calls.iter().filter(|call| is_assert_call(call)).count();
    let error_assertion_calls = calls.iter().filter(|call| is_err_assert_call(call)).count()
        + usize::from(body_text.contains("err == nil") || body_text.contains("err==nil"));
    let skip_calls = calls.iter().filter(|call| is_skip_call(call)).count();
    let production_calls = calls
        .iter()
        .filter(|call| !is_assert_call(call) && !is_test_infra_call(call))
        .count();
    let normalized = body_text.to_ascii_lowercase();

    Some(TestFunctionSummary {
        assertion_like_calls,
        error_assertion_calls,
        skip_calls,
        production_calls,
        has_todo_marker: normalized.contains("todo") || normalized.contains("fixme"),
    })
}

fn is_assert_call(call: &CallSite) -> bool {
    matches!(
        (call.receiver.as_deref(), call.name.as_str()),
        (
            Some("t"),
            "Error" | "Errorf" | "Fatal" | "Fatalf" | "Fail" | "FailNow"
        ) | (Some("assert" | "require"), _)
    )
}

fn is_err_assert_call(call: &CallSite) -> bool {
    matches!(
        (call.receiver.as_deref(), call.name.as_str()),
        (
            Some("assert" | "require"),
            "Error" | "Errorf" | "ErrorIs" | "ErrorContains" | "Panics" | "PanicsWithValue"
        ) | (Some("t"), "Fatal" | "Fatalf" | "Error" | "Errorf")
    )
}

fn is_skip_call(call: &CallSite) -> bool {
    matches!(
        (call.receiver.as_deref(), call.name.as_str()),
        (Some("t"), "Skip" | "SkipNow" | "Skipf")
    )
}

fn is_test_infra_call(call: &CallSite) -> bool {
    matches!(
        (call.receiver.as_deref(), call.name.as_str()),
        (
            Some("t"),
            "Helper"
                | "Run"
                | "Parallel"
                | "Cleanup"
                | "TempDir"
                | "Setenv"
                | "Skip"
                | "SkipNow"
                | "Skipf"
                | "Log"
                | "Logf"
        ) | (Some("assert" | "require"), _)
    )
}

pub(crate) fn first_string_literal(node: Node<'_>, source: &str) -> Option<String> {
    if matches!(
        node.kind(),
        "interpreted_string_literal" | "raw_string_literal"
    ) {
        let literal = source.get(node.byte_range())?;
        return Some(literal.trim_matches('"').trim_matches('`').to_string());
    }

    let mut cursor = node.walk();
    for child in node.named_children(&mut cursor) {
        if matches!(
            child.kind(),
            "interpreted_string_literal" | "raw_string_literal"
        ) {
            let literal = source.get(child.byte_range())?;
            return Some(literal.trim_matches('"').trim_matches('`').to_string());
        }
    }

    None
}

fn is_package_scope(node: Node<'_>) -> bool {
    let mut current = node.parent();
    while let Some(parent) = current {
        match parent.kind() {
            "function_declaration" | "method_declaration" | "func_literal" => return false,
            "source_file" => return true,
            _ => current = parent.parent(),
        }
    }

    false
}