use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
use std::path::{Path, PathBuf};
use serde::Serialize;
use crate::deps::graph::{FileGraph, Related, Source, Unnarrowed};
use crate::git::{ChangeKind, FileDiffStat};
use crate::loc::language::detect;
use crate::walk::{TEST_DIRS, is_test_file};
use super::protection::{Protection, Tests};
const RELIABLE: &[&str] = &[
"Elixir",
"Elixir Script",
"JavaScript",
"TypeScript",
"JSX",
"TSX",
"Kaikai",
];
pub fn graphed_language(path: &Path) -> Option<&'static str> {
detect(path)
.map(|spec| spec.name)
.filter(|name| crate::deps::is_supported(name))
}
pub fn is_reliable(path: &Path) -> bool {
graphed_language(path).is_some_and(|language| RELIABLE.contains(&language))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Role {
Source,
Test,
Other,
}
fn role(path: &Path) -> Role {
let in_test_dir = path
.components()
.filter_map(|c| c.as_os_str().to_str())
.any(|c| TEST_DIRS.contains(&c));
let is_script = graphed_language(path) == Some("Elixir Script");
if is_test_file(path) {
Role::Test
} else if in_test_dir || is_script {
Role::Other
} else {
Role::Source
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum Exposure {
Calls,
Refers,
Elsewhere,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct Dependent {
pub file: PathBuf,
pub exposure: Exposure,
pub calls: Vec<String>,
pub protection: Protection,
pub tests: usize,
pub tests_in_diff: usize,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct Narrowing {
pub file: PathBuf,
pub functions: Option<Vec<String>>,
pub reason: Option<String>,
}
fn reason(stat: &FileDiffStat, why: Unnarrowed) -> String {
match (stat.kind, why) {
(ChangeKind::Added, _) => "new file".to_string(),
(_, Unnarrowed::Language) => "its language does not tell functions".to_string(),
(_, Unnarrowed::NoLines) => "no changed line is known".to_string(),
(_, Unnarrowed::Outside(line)) => format!("line {line} is outside every function"),
}
}
pub type Changed<'a> = &'a FileDiffStat;
#[derive(Debug, Default, PartialEq)]
pub struct Structural {
pub origins: Vec<PathBuf>,
pub functions: Option<Vec<String>>,
pub narrowing: Vec<Narrowing>,
pub source_files: usize,
pub direct: Vec<Dependent>,
pub by_distance: Vec<usize>,
pub reached_files: Vec<Vec<PathBuf>>,
pub upper_bound: usize,
pub unavailable: Vec<(String, usize)>,
pub ambiguous: usize,
}
impl Structural {
pub fn reached(&self) -> usize {
self.by_distance.iter().sum()
}
pub fn exposed(&self) -> impl Iterator<Item = &Dependent> {
self.direct
.iter()
.filter(|d| d.exposure != Exposure::Elsewhere)
}
pub fn unprotected(&self) -> impl Iterator<Item = &Dependent> {
self.direct
.iter()
.filter(|d| d.exposure == Exposure::Calls && d.protection == Protection::None)
}
}
fn unavailable(changed: &[&Path]) -> Vec<(String, usize)> {
let mut by_language: BTreeMap<&str, usize> = BTreeMap::new();
for path in changed {
if let Some(language) = graphed_language(path).filter(|l| !RELIABLE.contains(l)) {
*by_language.entry(language).or_default() += 1;
}
}
by_language
.into_iter()
.map(|(language, files)| (language.to_string(), files))
.collect()
}
struct Reverse {
sources: Vec<Vec<usize>>,
tests: Tests,
}
impl Reverse {
fn of(graph: &FileGraph, roles: &[Role]) -> Self {
let mut sources = vec![Vec::new(); graph.files.len()];
let mut referring = vec![Vec::new(); graph.files.len()];
for (file, uses) in graph.uses.iter().enumerate() {
let into = match roles[file] {
Role::Source => &mut sources,
Role::Test => &mut referring,
Role::Other => continue,
};
for used in uses {
into[used.to].push(file);
}
}
let tests = Tests::of(
&graph.files,
referring,
|file| roles[file] == Role::Test,
|file| roles[file] == Role::Source,
);
Self { sources, tests }
}
fn levels_beyond(&self, frontier: &[usize], mut seen: HashSet<usize>) -> Vec<Vec<usize>> {
let mut levels = Vec::new();
let mut frontier = frontier.to_vec();
loop {
let mut next: Vec<usize> = frontier
.iter()
.flat_map(|&file| &self.sources[file])
.copied()
.filter(|file| seen.insert(*file))
.collect();
if next.is_empty() {
return levels;
}
next.sort_unstable();
levels.push(next.clone());
frontier = next;
}
}
fn levels(&self, origins: &[usize]) -> Vec<Vec<usize>> {
self.levels_beyond(origins, origins.iter().copied().collect())
}
fn levels_through(&self, origins: &[usize], calling: &[usize]) -> Vec<Vec<usize>> {
if calling.is_empty() {
return Vec::new();
}
let seen = origins.iter().chain(calling).copied().collect();
let mut levels = vec![calling.to_vec()];
levels.extend(self.levels_beyond(calling, seen));
levels
}
}
fn short_call(call: &str) -> String {
let mut parts: Vec<&str> = call.rsplitn(3, '.').collect();
parts.truncate(2);
parts.reverse();
parts.join(".")
}
fn function_of(call: &str) -> &str {
call.rsplit('.').next().unwrap_or(call)
}
fn exposure_of(calls: &[String], changed: Option<&BTreeSet<String>>) -> (Exposure, Vec<String>) {
let Some(changed) = changed else {
return (Exposure::Calls, calls.to_vec());
};
let hits: Vec<String> = calls
.iter()
.filter(|call| changed.contains(function_of(call)))
.cloned()
.collect();
match (hits.is_empty(), calls.is_empty()) {
(false, _) => (Exposure::Calls, hits),
(true, true) => (Exposure::Refers, Vec::new()),
(true, false) => (Exposure::Elsewhere, Vec::new()),
}
}
pub fn compute(
sources: Vec<(PathBuf, String)>,
changed: &[Changed<'_>],
related: Related,
) -> Structural {
let sources: Vec<Source> = sources
.into_iter()
.filter_map(|(path, text)| {
let language = graphed_language(&path)?.to_string();
Some(Source {
path,
language,
text,
})
})
.collect();
let known: HashSet<PathBuf> = sources.iter().map(|s| s.path.clone()).collect();
let graph = FileGraph::build(&sources, &known, None, related);
let roles: Vec<Role> = graph.files.iter().map(|f| role(f)).collect();
let reverse = Reverse::of(&graph, &roles);
let stat_of: HashMap<&Path, Changed> = changed.iter().map(|c| (c.path.as_path(), *c)).collect();
let in_diff = |file: usize| stat_of.contains_key(graph.files[file].as_path());
let origins: BTreeMap<usize, Result<BTreeSet<String>, Unnarrowed>> = (0..graph.files.len())
.filter(|&f| roles[f] == Role::Source && in_diff(f))
.map(|f| {
let lines = stat_of[graph.files[f].as_path()].lines_in(&sources[f].text);
(f, sources[f].changed_functions(lines))
})
.collect();
let starts: Vec<usize> = origins.keys().copied().collect();
let levels = reverse.levels(&starts);
let dependent = |&file: &usize| {
let mut exposure = Exposure::Elsewhere;
let mut calls = BTreeSet::new();
for used in &graph.uses[file] {
let Some(changed) = origins.get(&used.to) else {
continue;
};
let (how, telling) = exposure_of(&used.calls, changed.as_ref().ok());
exposure = exposure.min(how);
calls.extend(telling.iter().map(|c| short_call(c)));
}
let tests = reverse.tests.direct(file);
Dependent {
file: graph.files[file].clone(),
exposure,
calls: calls.into_iter().collect(),
protection: reverse.tests.protection(file, &reverse.sources[file]),
tests: tests.len(),
tests_in_diff: reverse.tests.own(file).filter(|&t| in_diff(t)).count(),
}
};
let mut direct: Vec<(usize, Dependent)> = levels
.first()
.into_iter()
.flatten()
.map(|file| (*file, dependent(file)))
.collect();
direct.sort_by(|(_, a), (_, b)| {
(a.exposure, std::cmp::Reverse(a.protection), &a.file).cmp(&(
b.exposure,
std::cmp::Reverse(b.protection),
&b.file,
))
});
let mut narrowing: Vec<Narrowing> = origins
.iter()
.map(|(&file, told)| {
let path = &graph.files[file];
Narrowing {
file: path.clone(),
functions: told.as_ref().ok().map(|f| f.iter().cloned().collect()),
reason: told
.as_ref()
.err()
.map(|&why| reason(stat_of[path.as_path()], why)),
}
})
.collect();
narrowing.sort_by(|a, b| a.file.cmp(&b.file));
let narrowed: Vec<&Vec<String>> = narrowing
.iter()
.filter_map(|n| n.functions.as_ref())
.collect();
let functions: Option<Vec<String>> = (!narrowed.is_empty()).then(|| {
let all: BTreeSet<&String> = narrowed.into_iter().flatten().collect();
all.into_iter().cloned().collect()
});
let calling: Vec<usize> = direct
.iter()
.filter(|(_, d)| d.exposure == Exposure::Calls)
.map(|(file, _)| *file)
.collect();
let through = reverse.levels_through(&starts, &calling);
Structural {
origins: narrowing.iter().map(|n| n.file.clone()).collect(),
functions,
narrowing,
source_files: roles.iter().filter(|r| **r == Role::Source).count(),
direct: direct.into_iter().map(|(_, d)| d).collect(),
by_distance: through.iter().map(Vec::len).collect(),
reached_files: through
.iter()
.map(|level| level.iter().map(|&f| graph.files[f].clone()).collect())
.collect(),
upper_bound: levels.iter().map(Vec::len).sum(),
unavailable: unavailable(&changed.iter().map(|c| c.path.as_path()).collect::<Vec<_>>()),
ambiguous: graph.ambiguous,
}
}
#[cfg(test)]
#[path = "structural_test.rs"]
mod tests;