weavatrix-rust 2.1.1

Protocol-independent Rust repository intelligence: typed evidence graphs for impact, architecture, APIs, Git, search, and memory
Documentation
use blazingly_json::Value;
use std::collections::BTreeMap;
use std::path::Path;

#[derive(Debug, Clone)]
pub(super) struct Declaration {
    pub ecosystem: &'static str,
    pub name: String,
    pub manifest: String,
    pub scope: String,
}

pub(super) fn parse(path: &Path, relative: &str, text: &str) -> Vec<Declaration> {
    // Editors on Windows save manifests with a UTF-8 BOM; a line parser that
    // sees `\u{feff}[dependencies]` misses every declaration after it.
    let text = text.trim_start_matches('\u{feff}');
    match path.file_name().and_then(|name| name.to_str()) {
        Some("package.json") => package_json(relative, text),
        Some("Cargo.toml") => sectioned(relative, text, "cargo", &["dependencies"]),
        Some("go.mod") => go_mod(relative, text),
        Some("requirements.txt") => requirements(relative, text),
        Some("pyproject.toml") => pyproject(relative, text),
        _ => Vec::new(),
    }
}

fn package_json(relative: &str, text: &str) -> Vec<Declaration> {
    let Ok(document) = blazingly_json::from_str::<Value>(text) else {
        return Vec::new();
    };
    let mut result = Vec::new();
    for scope in [
        "dependencies",
        "devDependencies",
        "peerDependencies",
        "optionalDependencies",
    ] {
        let Some(entries) = document.get(scope).and_then(Value::as_object) else {
            continue;
        };
        result.extend(entries.keys().map(|name| Declaration {
            ecosystem: "npm",
            name: name.clone(),
            manifest: relative.to_owned(),
            scope: scope.to_owned(),
        }));
    }
    result
}

pub(super) fn npm_required_peer_names(text: &str) -> Vec<String> {
    let Ok(document) = blazingly_json::from_str::<Value>(text) else {
        return Vec::new();
    };
    let Some(peers) = document.get("peerDependencies").and_then(Value::as_object) else {
        return Vec::new();
    };
    let metadata = document
        .get("peerDependenciesMeta")
        .and_then(Value::as_object);
    let mut result = peers
        .keys()
        .filter(|name| {
            !metadata
                .and_then(|entries| entries.get(*name))
                .and_then(Value::as_object)
                .and_then(|entry| entry.get("optional"))
                .and_then(Value::as_bool)
                .unwrap_or(false)
        })
        .cloned()
        .collect::<Vec<_>>();
    result.sort();
    result
}

fn sectioned(
    relative: &str,
    text: &str,
    ecosystem: &'static str,
    sections: &[&str],
) -> Vec<Declaration> {
    let mut result = Vec::new();
    let mut section = String::new();
    for raw in text.lines() {
        let line = raw.split('#').next().unwrap_or_default().trim();
        if line.starts_with('[') && line.ends_with(']') {
            section = line.trim_matches(['[', ']']).to_ascii_lowercase();
            continue;
        }
        if !sections
            .iter()
            .any(|name| section == *name || section.ends_with(&format!(".{name}")))
        {
            continue;
        }
        let Some((name, _)) = line.split_once('=') else {
            continue;
        };
        let name = name.trim().trim_matches(['"', '\'']);
        if !name.is_empty() {
            result.push(Declaration {
                ecosystem,
                name: name.to_owned(),
                manifest: relative.to_owned(),
                scope: section.clone(),
            });
        }
    }
    result
}

fn go_mod(relative: &str, text: &str) -> Vec<Declaration> {
    let mut result = Vec::new();
    let mut in_require = false;
    for raw in text.lines() {
        let line = raw.split("//").next().unwrap_or_default().trim();
        if line == "require (" {
            in_require = true;
            continue;
        }
        if in_require && line == ")" {
            in_require = false;
            continue;
        }
        let value = line
            .strip_prefix("require ")
            .or_else(|| in_require.then_some(line));
        let Some(value) = value else {
            continue;
        };
        let name = value.split_whitespace().next().unwrap_or_default();
        if !name.is_empty() {
            result.push(Declaration {
                ecosystem: "go",
                name: name.to_owned(),
                manifest: relative.to_owned(),
                scope: "require".to_owned(),
            });
        }
    }
    result
}

fn requirements(relative: &str, text: &str) -> Vec<Declaration> {
    text.lines()
        .filter_map(|raw| {
            let line = raw.split('#').next()?.trim();
            if line.is_empty() || line.starts_with('-') {
                return None;
            }
            let name = line
                .split(['=', '<', '>', '!', '~', '[', ';'])
                .next()
                .unwrap_or_default()
                .trim();
            (!name.is_empty()).then(|| Declaration {
                ecosystem: "python",
                name: name.to_owned(),
                manifest: relative.to_owned(),
                scope: "requirements".to_owned(),
            })
        })
        .collect()
}

fn pyproject(relative: &str, text: &str) -> Vec<Declaration> {
    let mut result = sectioned(
        relative,
        text,
        "python",
        &["project.dependencies", "tool.poetry.dependencies"],
    );
    result.retain(|item| item.name != "python");
    result
}

pub(super) fn duplicates(declarations: &[Declaration]) -> BTreeMap<String, Vec<&Declaration>> {
    let mut grouped = BTreeMap::<String, Vec<&Declaration>>::new();
    for declaration in declarations {
        grouped
            .entry(format!(
                "{}:{}:{}",
                declaration.manifest,
                declaration.ecosystem,
                normalize(declaration.ecosystem, &declaration.name)
            ))
            .or_default()
            .push(declaration);
    }
    grouped.retain(|_, items| items.len() > 1);
    grouped
}

pub(super) fn normalize(ecosystem: &str, name: &str) -> String {
    let normalized = name.trim().to_ascii_lowercase();
    if matches!(ecosystem, "cargo" | "python") {
        normalized.replace('-', "_")
    } else {
        normalized
    }
}