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 std::path::Path;

use toml::Value;

use crate::analysis::{ParsedFile, ParsedFunction};
use crate::index::RepositoryIndex;
use crate::io::{DEFAULT_MAX_BYTES, read_to_string_limited};
use crate::model::{Finding, Severity};

pub(crate) const BINDING_LOCATION: &str = file!();

use super::{function_finding, is_scanner_infra_file};

#[derive(Debug, Clone)]
struct BodyLine {
    line: usize,
    text: String,
    in_loop: bool,
}

pub(crate) fn runtime_function_findings(
    file: &ParsedFile,
    function: &ParsedFunction,
) -> Vec<Finding> {
    if is_scanner_infra_file(file) {
        return Vec::new();
    }

    if function.is_test_function {
        return Vec::new();
    }

    let lines = body_lines(function);
    let body = function.body_text.as_str();
    let request_path = is_axum_handler(file, function)
        || is_actix_handler(file, function)
        || is_tonic_handler(file, function);
    let mut findings = Vec::new();

    if let Some(line) = first_line_with_any(
        body,
        function.fingerprint.start_line,
        &[
            "Runtime::new(",
            "Builder::new_multi_thread(",
            "Builder::new_current_thread(",
        ],
    ) && !main_like(function)
    {
        findings.push(function_finding(
            file,
            function,
            "rust_tokio_runtime_built_per_call",
            Severity::Warning,
            line,
            format!(
                "function {} builds a Tokio runtime in the call path",
                function.fingerprint.name
            ),
            vec![
                "Tokio runtimes are usually process-level infrastructure instead of per-call state"
                    .to_string(),
            ],
        ));
    }

    if request_path {
        if let Some(line) = first_line_with_any(
            body,
            function.fingerprint.start_line,
            &[
                "std::env::var(",
                "env::var(",
                "std::env::var_os(",
                "env::var_os(",
            ],
        ) {
            findings.push(function_finding(
                file,
                function,
                "rust_env_var_read_in_request_path",
                Severity::Warning,
                line,
                format!(
                    "function {} reads environment configuration on a request path",
                    function.fingerprint.name
                ),
                vec![
                    "request handlers usually read already-initialized config rather than hitting std::env per request"
                        .to_string(),
                ],
            ));
        }

        if has_import_fragment(file, "axum")
            && let Some(line) =
                first_line_with_any(body, function.fingerprint.start_line, &["Router::new("])
        {
            findings.push(function_finding(
                file,
                function,
                "rust_axum_router_built_in_handler",
                Severity::Warning,
                line,
                format!(
                    "function {} builds an Axum router inside a handler path",
                    function.fingerprint.name
                ),
                vec![
                    "routers are typically assembled at startup rather than rebuilt per request"
                        .to_string(),
                ],
            ));
        }

        if has_import_fragment(file, "tonic")
            && let Some(line) = first_line_with_any(
                body,
                function.fingerprint.start_line,
                &[
                    ".connect().await",
                    "Endpoint::from_static(",
                    "Endpoint::from_shared(",
                    "Channel::from_static(",
                    "Channel::from_shared(",
                ],
            )
        {
            findings.push(function_finding(
                file,
                function,
                "rust_tonic_channel_connect_per_request",
                Severity::Warning,
                line,
                format!(
                    "function {} establishes a tonic transport channel on a request path",
                    function.fingerprint.name
                ),
                vec![
                    "reuse a client/channel when possible instead of dialing a transport on each request"
                        .to_string(),
                ],
            ));
        }
    }

    if let Some(line) = lines.iter().find(|line| {
        line.in_loop && line.text.contains(".clone()") && looks_like_heavy_clone(line.text.as_str())
    }) {
        findings.push(function_finding(
            file,
            function,
            "rust_clone_heavy_state_in_loop",
            Severity::Info,
            line.line,
            format!(
                "function {} clones likely heavy state inside a loop",
                function.fingerprint.name
            ),
            vec![
                format!("clone expression={}", line.text.trim()),
                "consider borrowing, moving the clone outside the loop, or sharing cheaper state handles"
                    .to_string(),
            ],
        ));
    }

    findings
}

pub(crate) fn runtime_file_findings(file: &ParsedFile, index: &RepositoryIndex) -> Vec<Finding> {
    let manifest_path = index.root().join("Cargo.toml");
    if !should_emit_workspace_manifest_finding(file, index.root()) || !manifest_path.exists() {
        return Vec::new();
    }

    let Ok(source) = read_to_string_limited(&manifest_path, DEFAULT_MAX_BYTES) else {
        return Vec::new();
    };
    let Ok(parsed) = source.parse::<Value>() else {
        return Vec::new();
    };
    let Some(workspace) = parsed.get("workspace").and_then(Value::as_table) else {
        return Vec::new();
    };
    let member_count = workspace
        .get("members")
        .and_then(Value::as_array)
        .map(|members| members.len())
        .unwrap_or(0);
    if member_count < 2 || workspace.get("resolver").is_some() {
        return Vec::new();
    }

    vec![Finding {
        rule_id: "rust_workspace_missing_resolver".to_string(),
        severity: Severity::Info,
        path: manifest_path,
        function_name: None,
        start_line: 1,
        end_line: 1,
        message: "workspace Cargo.toml does not declare an explicit dependency resolver"
            .to_string(),
        evidence: vec![format!("workspace_members={member_count}")],
    }]
}

fn should_emit_workspace_manifest_finding(file: &ParsedFile, root: &Path) -> bool {
    let candidates = [
        root.join("src/lib.rs"),
        root.join("src/main.rs"),
        root.join("lib.rs"),
        root.join("main.rs"),
    ];
    candidates.iter().any(|candidate| candidate == &file.path)
}

fn body_lines(function: &ParsedFunction) -> Vec<BodyLine> {
    let mut brace_depth = 0usize;
    let mut loop_exit_depths = Vec::new();
    let mut lines = Vec::new();

    for (offset, raw_line) in function.body_text.lines().enumerate() {
        let absolute_line = function.body_start_line + offset;
        let stripped = raw_line.split("//").next().unwrap_or("").trim().to_string();
        let closing_braces = stripped
            .chars()
            .filter(|character| *character == '}')
            .count();
        for _ in 0..closing_braces {
            brace_depth = brace_depth.saturating_sub(1);
            while loop_exit_depths
                .last()
                .is_some_and(|exit_depth| *exit_depth > brace_depth)
            {
                loop_exit_depths.pop();
            }
        }

        let starts_loop = contains_keyword(&stripped, "for")
            || contains_keyword(&stripped, "while")
            || contains_keyword(&stripped, "loop");
        let in_loop = !loop_exit_depths.is_empty() || starts_loop;
        let opening_braces = stripped
            .chars()
            .filter(|character| *character == '{')
            .count();
        if starts_loop {
            loop_exit_depths.push(brace_depth + opening_braces.max(1));
        }
        brace_depth += opening_braces;

        lines.push(BodyLine {
            line: absolute_line,
            text: stripped,
            in_loop,
        });
    }

    lines
}

fn contains_keyword(line: &str, keyword: &str) -> bool {
    let bytes = line.as_bytes();
    let keyword_bytes = keyword.as_bytes();
    if keyword_bytes.is_empty() || bytes.len() < keyword_bytes.len() {
        return false;
    }

    for start in 0..=bytes.len() - keyword_bytes.len() {
        if &bytes[start..start + keyword_bytes.len()] != keyword_bytes {
            continue;
        }

        let left_ok =
            start == 0 || (!bytes[start - 1].is_ascii_alphanumeric() && bytes[start - 1] != b'_');
        let right_index = start + keyword_bytes.len();
        let right_ok = right_index == bytes.len()
            || (!bytes[right_index].is_ascii_alphanumeric() && bytes[right_index] != b'_');
        if left_ok && right_ok {
            return true;
        }
    }

    false
}

fn first_line_with_any(body: &str, base_line: usize, markers: &[&str]) -> Option<usize> {
    body.lines()
        .enumerate()
        .find(|(_, line)| markers.iter().any(|marker| line.contains(marker)))
        .map(|(offset, _)| base_line + offset)
}

fn has_import_fragment(file: &ParsedFile, fragment: &str) -> bool {
    file.imports
        .iter()
        .any(|import| import.path.contains(fragment) || import.alias.contains(fragment))
}

fn is_axum_handler(file: &ParsedFile, function: &ParsedFunction) -> bool {
    has_import_fragment(file, "axum")
        && ["State<", "Json<", "Path<", "Query<", "Form<"]
            .iter()
            .any(|marker| function.signature_text.contains(marker))
}

fn is_actix_handler(file: &ParsedFile, function: &ParsedFunction) -> bool {
    has_import_fragment(file, "actix_web")
        && ["HttpRequest", "web::Json<", "web::Data<", "Responder"]
            .iter()
            .any(|marker| function.signature_text.contains(marker))
}

fn is_tonic_handler(file: &ParsedFile, function: &ParsedFunction) -> bool {
    has_import_fragment(file, "tonic")
        && ["Request<", "tonic::Request<", "Streaming<"]
            .iter()
            .any(|marker| function.signature_text.contains(marker))
}

fn main_like(function: &ParsedFunction) -> bool {
    matches!(function.fingerprint.name.as_str(), "main" | "build_runtime")
}

fn looks_like_heavy_clone(line: &str) -> bool {
    [
        "state.clone()",
        "config.clone()",
        "payload.clone()",
        "request.clone()",
        "response.clone()",
        "body.clone()",
        "bytes.clone()",
        "client.clone()",
        "channel.clone()",
    ]
    .iter()
    .any(|pattern| line.contains(pattern))
}