use std::collections::{HashMap, HashSet};
use serde::Serialize;
use crate::db::Database;
use crate::errors::{Result, TokenSaveError};
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct ImportSite {
pub file: String,
pub line: u32,
pub imported: String,
pub statement: Option<String>,
pub resolved_file: String,
pub lazy: bool,
pub type_only: bool,
}
#[derive(Debug, Clone, Serialize)]
pub struct ModuleDependency {
pub from: String,
pub to: String,
pub sites: Vec<ImportSite>,
}
#[derive(Debug, Clone, Default)]
pub struct ModuleImportGraph {
edges: HashMap<(String, String), Vec<ImportSite>>,
modules: HashSet<String>,
}
impl ModuleImportGraph {
#[must_use]
pub fn adjacency(&self) -> HashMap<String, HashSet<String>> {
let mut adj: HashMap<String, HashSet<String>> = self
.modules
.iter()
.map(|module| (module.clone(), HashSet::new()))
.collect();
for (from, to) in self.edges.keys() {
adj.entry(from.clone()).or_default().insert(to.clone());
}
adj
}
#[must_use]
pub fn dependencies(&self) -> Vec<ModuleDependency> {
let mut out: Vec<ModuleDependency> = self
.edges
.iter()
.map(|((from, to), sites)| {
let mut sites = sites.clone();
sites.sort_by(|a, b| a.file.cmp(&b.file).then(a.line.cmp(&b.line)));
ModuleDependency {
from: from.clone(),
to: to.clone(),
sites,
}
})
.collect();
out.sort_by(|a, b| a.from.cmp(&b.from).then(a.to.cmp(&b.to)));
out
}
#[must_use]
pub fn cycles(&self) -> Vec<Vec<String>> {
let adj = self.adjacency();
let mut cycles: Vec<Vec<String>> = super::scc::tarjan_scc(&adj)
.into_iter()
.filter(|scc| super::scc::is_cyclic_scc(scc, &adj))
.collect();
for cycle in &mut cycles {
cycle.sort_unstable();
}
cycles.sort();
cycles
}
#[must_use]
pub fn cycles_without(&self, from: &str, to: &str) -> Vec<Vec<String>> {
let mut adj = self.adjacency();
if let Some(targets) = adj.get_mut(from) {
targets.remove(to);
}
let mut cycles: Vec<Vec<String>> = super::scc::tarjan_scc(&adj)
.into_iter()
.filter(|scc| super::scc::is_cyclic_scc(scc, &adj))
.collect();
for cycle in &mut cycles {
cycle.sort_unstable();
}
cycles.sort();
cycles
}
}
#[must_use]
pub fn module_of(file_path: &str, depth: usize) -> String {
let depth = depth.max(1);
let components: Vec<&str> = file_path.split('/').collect();
let dirs = components.len().saturating_sub(1);
if dirs == 0 {
return file_path.to_string();
}
components[..depth.min(dirs)].join("/")
}
fn is_type_only(statement: Option<&str>) -> bool {
let Some(text) = statement else {
return false;
};
let trimmed = text.trim_start();
trimmed.starts_with("import type ")
|| trimmed.starts_with("export type ")
|| trimmed.starts_with("import type{")
}
impl Database {
pub async fn build_module_import_graph(&self, depth: usize) -> Result<ModuleImportGraph> {
let sql = "SELECT DISTINCT n1.file_path, n1.start_line, n1.name, n1.signature, \
n2.file_path, COALESCE(parent.kind, 'file') \
FROM edges e \
JOIN nodes n1 ON e.source = n1.id \
JOIN nodes n2 ON e.target = n2.id \
LEFT JOIN nodes parent ON parent.id = n1.parent_id \
WHERE e.kind = 'uses' AND n1.kind = 'use' \
AND n1.file_path != n2.file_path";
let mut rows = self
.conn()
.query(sql, ())
.await
.map_err(|e| TokenSaveError::Database {
message: format!("failed to query import graph: {e}"),
operation: "build_module_import_graph".to_string(),
})?;
let mut graph = ModuleImportGraph::default();
while let Some(row) = rows.next().await.map_err(|e| TokenSaveError::Database {
message: format!("failed to read import row: {e}"),
operation: "build_module_import_graph".to_string(),
})? {
let file: String = row.get(0).unwrap_or_default();
let line: u32 = row.get(1).unwrap_or(0);
let imported: String = row.get(2).unwrap_or_default();
let statement: Option<String> = row.get(3).ok();
let resolved_file: String = row.get(4).unwrap_or_default();
let parent_kind: String = row.get(5).unwrap_or_else(|_| "file".to_string());
let from = module_of(&file, depth);
let to = module_of(&resolved_file, depth);
graph.modules.insert(from.clone());
graph.modules.insert(to.clone());
if from == to {
continue;
}
let site = ImportSite {
file,
line: line.saturating_add(1),
imported,
type_only: is_type_only(statement.as_deref()),
statement,
resolved_file,
lazy: parent_kind != "file",
};
let sites = graph.edges.entry((from, to)).or_default();
if !sites.contains(&site) {
sites.push(site);
}
}
Ok(graph)
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
#[test]
fn module_of_groups_by_depth() {
assert_eq!(module_of("anomaly/alerts/slack.py", 1), "anomaly");
assert_eq!(module_of("anomaly/alerts/slack.py", 2), "anomaly/alerts");
assert_eq!(module_of("anomaly/alerts/slack.py", 9), "anomaly/alerts");
}
#[test]
fn a_top_level_file_is_its_own_module() {
assert_eq!(module_of("setup.py", 1), "setup.py");
assert_eq!(module_of("setup.py", 3), "setup.py");
}
#[test]
fn depth_zero_is_treated_as_one() {
assert_eq!(module_of("a/b/c.py", 0), "a");
}
#[test]
fn type_only_needs_the_keyword_not_just_the_word() {
assert!(is_type_only(Some("import type { Foo } from './foo'")));
assert!(!is_type_only(Some("import { type_registry } from './x'")));
assert!(!is_type_only(Some("from typing import TYPE_CHECKING")));
assert!(!is_type_only(None));
}
}