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 crate::analysis::{FieldSummary, ParsedFile, ParsedFunction, StructSummary};
use crate::model::Finding;

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

use super::{file_finding, function_finding, matches_token, struct_severity};

#[path = "api_design_state.rs"]
mod api_design_state;
#[path = "api_design_surface.rs"]
mod api_design_surface;

use self::api_design_state::{
    builder_state_file_findings, builder_state_function_findings, serde_contract_findings,
    shared_state_findings,
};
use self::api_design_surface::api_surface_findings;

const OPTION_BAG_THRESHOLD: usize = 4;

pub(crate) fn api_design_file_findings(file: &ParsedFile) -> Vec<Finding> {
    if file.is_test_file {
        return Vec::new();
    }

    let mut findings = Vec::new();
    findings.extend(shared_state_findings(file));
    findings.extend(serde_contract_findings(file));
    findings.extend(builder_state_file_findings(file));
    findings
}

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

    let mut findings = Vec::new();
    findings.extend(api_surface_findings(file, function));
    findings.extend(builder_state_function_findings(file, function));
    findings
}

fn is_public_api(function: &ParsedFunction) -> bool {
    let signature = function.signature_text.trim_start();
    signature.starts_with("pub ") || signature.starts_with("pub(")
}

fn is_library_like(file: &ParsedFile) -> bool {
    let path = file.path.to_string_lossy().to_ascii_lowercase();
    !path.ends_with("main.rs") && !path.contains("/bin/") && !path.contains("/src/cli/")
}

fn builder_internal(function: &ParsedFunction) -> bool {
    function
        .fingerprint
        .receiver_type
        .as_deref()
        .is_some_and(|receiver| receiver.ends_with("Builder"))
        || function.fingerprint.name.starts_with("with_")
        || function.fingerprint.name.starts_with("set_")
}

fn return_type_text(signature_text: &str) -> Option<String> {
    let header = signature_text.replace('\n', " ");
    let (_, return_text) = header.split_once("->")?;
    let return_text = return_text.trim();
    let return_text = return_text
        .split(" where ")
        .next()
        .unwrap_or(return_text)
        .trim();
    (!return_text.is_empty()).then(|| return_text.to_string())
}

fn parameter_entries(signature_text: &str) -> Vec<String> {
    let Some(start) = signature_text.find('(') else {
        return Vec::new();
    };
    let mut depth = 0usize;
    let mut end = None;
    for (offset, character) in signature_text[start..].char_indices() {
        match character {
            '(' => depth += 1,
            ')' => {
                depth = depth.saturating_sub(1);
                if depth == 0 {
                    end = Some(start + offset);
                    break;
                }
            }
            _ => {}
        }
    }
    let Some(end) = end else {
        return Vec::new();
    };
    split_top_level_commas(&signature_text[start + 1..end])
}

fn split_top_level_commas(text: &str) -> Vec<String> {
    let mut parts = Vec::new();
    let mut current = String::new();
    let mut angle_depth = 0usize;
    let mut paren_depth = 0usize;
    let mut bracket_depth = 0usize;
    let mut in_single_quote = false;

    for character in text.chars() {
        match character {
            '\'' => in_single_quote = !in_single_quote,
            '<' if !in_single_quote => angle_depth += 1,
            '>' if !in_single_quote => angle_depth = angle_depth.saturating_sub(1),
            '(' if !in_single_quote => paren_depth += 1,
            ')' if !in_single_quote => paren_depth = paren_depth.saturating_sub(1),
            '[' if !in_single_quote => bracket_depth += 1,
            ']' if !in_single_quote => bracket_depth = bracket_depth.saturating_sub(1),
            ',' if !in_single_quote
                && angle_depth == 0
                && paren_depth == 0
                && bracket_depth == 0 =>
            {
                let piece = current.trim();
                if !piece.is_empty() {
                    parts.push(piece.to_string());
                }
                current.clear();
                continue;
            }
            _ => {}
        }
        current.push(character);
    }

    let tail = current.trim();
    if !tail.is_empty() {
        parts.push(tail.to_string());
    }

    parts
}

fn parameter_name_and_type(entry: &str) -> Option<(String, String)> {
    let trimmed = entry.trim();
    if trimmed.is_empty() || trimmed.contains("self") {
        return None;
    }
    let (name, type_text) = trimmed.split_once(':')?;
    let name = name
        .trim()
        .trim_start_matches("mut ")
        .trim_start_matches("pub ")
        .to_string();
    let type_text = type_text.trim().to_string();
    (!name.is_empty() && !type_text.is_empty()).then_some((name, type_text))
}

fn normalized_type(type_text: &str) -> String {
    type_text
        .chars()
        .filter(|character| !character.is_whitespace())
        .collect()
}

fn import_alias_for<'a>(file: &'a ParsedFile, path_suffix: &str) -> Vec<&'a str> {
    file.imports
        .iter()
        .filter(|import| import.path.ends_with(path_suffix))
        .map(|import| import.alias.as_str())
        .collect()
}

fn return_type_uses_anyhow_like_result(file: &ParsedFile, return_type: &str) -> bool {
    let normalized = normalized_type(return_type).to_ascii_lowercase();
    if normalized.contains("anyhow::result<")
        || normalized.contains("eyre::result<")
        || normalized.contains("color_eyre::result<")
        || normalized.contains("anyhow::error")
        || normalized.contains("eyre::report")
    {
        return true;
    }

    import_alias_for(file, "::Result").into_iter().any(|alias| {
        let normalized_alias = alias.to_ascii_lowercase();
        normalized.starts_with(&format!("{normalized_alias}<"))
            && file.imports.iter().any(|import| {
                import.alias == alias
                    && (import.path.starts_with("anyhow::")
                        || import.path.starts_with("eyre::")
                        || import.path.starts_with("color_eyre::"))
            })
    })
}

fn return_type_uses_box_dyn_error(file: &ParsedFile, return_type: &str) -> bool {
    let normalized = normalized_type(return_type);
    if normalized.contains("Box<dynstd::error::Error")
        || normalized.contains("Box<dyncore::error::Error")
    {
        return true;
    }

    import_alias_for(file, "::Error").into_iter().any(|alias| {
        normalized.contains(&format!("Box<dyn{alias}"))
            && file.imports.iter().any(|import| {
                import.alias == alias
                    && (import.path.ends_with("std::error::Error")
                        || import.path.ends_with("core::error::Error"))
            })
    })
}

fn is_borrowed_string_type(type_text: &str) -> bool {
    matches!(
        normalized_type(type_text).as_str(),
        "&String" | "&std::string::String"
    )
}

fn is_borrowed_vec_type(type_text: &str) -> bool {
    let normalized = normalized_type(type_text);
    normalized.starts_with("&Vec<") || normalized.starts_with("&std::vec::Vec<")
}

fn is_borrowed_pathbuf_type(file: &ParsedFile, type_text: &str) -> bool {
    let normalized = normalized_type(type_text);
    if normalized.starts_with("&PathBuf") || normalized.starts_with("&std::path::PathBuf") {
        return true;
    }

    import_alias_for(file, "std::path::PathBuf")
        .into_iter()
        .any(|alias| normalized.starts_with(&format!("&{alias}")))
}

fn contains_interior_mutability(type_text: &str) -> bool {
    [
        "Mutex<",
        "RwLock<",
        "RefCell<",
        "Cell<",
        "OnceCell<",
        "LazyCell<",
    ]
    .iter()
    .any(|needle| type_text.contains(needle))
}

fn contains_global_lock_state(type_text: &str) -> bool {
    contains_interior_mutability(type_text)
        || ((type_text.contains("OnceLock<")
            || type_text.contains("Lazy<")
            || type_text.contains("LazyLock<"))
            && contains_interior_mutability(type_text))
}

fn is_arc_mutex_option_type(type_text: &str) -> bool {
    type_text.contains("Arc<")
        && (type_text.contains("Mutex<Option<") || type_text.contains("RwLock<Option<"))
}

fn is_mutex_collection_type(type_text: &str) -> bool {
    (type_text.contains("Mutex<") || type_text.contains("RwLock<"))
        && ["Vec<", "HashMap<", "BTreeMap<", "HashSet<", "VecDeque<"]
            .iter()
            .any(|needle| type_text.contains(needle))
}

fn is_rc_refcell_type(type_text: &str) -> bool {
    type_text.contains("Rc<RefCell<") || type_text.contains("Rc<std::cell::RefCell<")
}

fn graph_or_ui_shape(summary: &StructSummary) -> bool {
    matches_token(
        &summary.name,
        &["node", "tree", "graph", "widget", "ui", "scene", "dom"],
    )
}

fn central_state_name(name: &str) -> bool {
    matches_token(name, &["state", "store", "cache", "manager", "registry"])
}

fn attribute_has(attributes: &[String], needle: &str) -> bool {
    let needle = needle.to_ascii_lowercase();
    attributes
        .iter()
        .any(|attribute| attribute.to_ascii_lowercase().contains(&needle))
}

fn strict_contract_name(name: &str) -> bool {
    matches_token(
        name,
        &["config", "settings", "request", "options", "params"],
    )
}

fn required_like_field(summary: &StructSummary, field: &FieldSummary) -> bool {
    strict_contract_name(&summary.name)
        || matches_token(
            &field.name,
            &[
                "id", "kind", "type", "mode", "status", "endpoint", "host", "path", "url", "method",
            ],
        )
}

fn flatten_catchall_type(type_text: &str) -> bool {
    let normalized = normalized_type(type_text);
    normalized.contains("HashMap<")
        || normalized.contains("BTreeMap<")
        || normalized.contains("serde_json::Value")
        || normalized.contains("Map<")
}

fn enum_like_string_field(name: &str) -> bool {
    matches_token(
        name,
        &["kind", "type", "status", "state", "mode", "role", "level"],
    )
}

fn config_like_name(name: &str) -> bool {
    matches_token(
        name,
        &["config", "options", "request", "settings", "params"],
    )
}

fn has_validation_method(file: &ParsedFile, type_name: &str) -> bool {
    file.functions.iter().any(|function| {
        function.fingerprint.receiver_type.as_deref() == Some(type_name)
            && matches!(
                function.fingerprint.name.as_str(),
                "validate" | "check" | "ensure_valid" | "build"
            )
            && body_has_validation_markers(&function.body_text)
    })
}

fn body_has_validation_markers(body_text: &str) -> bool {
    [
        "validate(",
        "ensure!",
        "ok_or",
        "ok_or_else",
        "is_none()",
        "Err(",
        "bail!",
        "missing",
    ]
    .iter()
    .any(|marker| body_text.contains(marker))
}

fn state_like_name(name: &str) -> bool {
    matches_token(
        name,
        &[
            "state",
            "status",
            "session",
            "connection",
            "job",
            "task",
            "process",
        ],
    )
}

fn constructor_like_name(name: &str) -> bool {
    name == "new"
        || name.starts_with("new_")
        || name.starts_with("create")
        || name.starts_with("build")
        || name.starts_with("finish")
        || name.starts_with("from_")
}

fn body_shows_partial_init_escape(body_text: &str) -> bool {
    ((body_text.contains(": None") || body_text.contains(":None")) && body_text.contains('{'))
        || body_text.contains("..Default::default()")
}