use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use crate::file_discovery::FileKind;
use crate::incremental::{
IncrementalDb, QueryKind, QueryLogEntry, SourceFile, include_edges, package_edges,
};
use crate::project::include::{IncludeEdgeKey, IncludeKind, IncludeTarget};
use crate::project::package::{PackageEdgeKey, PackageKind, PackageTarget};
#[derive(Debug, Clone)]
pub struct FileFacts {
pub path: PathBuf,
pub include_edges: Vec<IncludeEdgeKey>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResolvedInclude {
pub from: PathBuf,
pub to: PathBuf,
pub kind: IncludeKind,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UnresolvedInclude {
pub from: PathBuf,
pub target: IncludeTarget,
pub kind: IncludeKind,
}
#[derive(Debug, Default)]
pub struct IncludeGraph {
edges: HashMap<PathBuf, Vec<ResolvedInclude>>,
included_by: HashMap<PathBuf, Vec<PathBuf>>,
unresolved: Vec<UnresolvedInclude>,
reachable: HashSet<PathBuf>,
cycles: Vec<Vec<PathBuf>>,
}
impl IncludeGraph {
pub fn build(files: &[FileFacts], root: Option<&Path>) -> Self {
let members: HashSet<&Path> = files.iter().map(|f| f.path.as_path()).collect();
let mut edges: HashMap<PathBuf, Vec<ResolvedInclude>> = HashMap::new();
let mut included_by: HashMap<PathBuf, Vec<PathBuf>> = HashMap::new();
let mut unresolved: Vec<UnresolvedInclude> = Vec::new();
for file in files {
let mut outgoing = Vec::new();
for edge in &file.include_edges {
match &edge.target {
IncludeTarget::Path(to) if members.contains(to.as_path()) => {
outgoing.push(ResolvedInclude {
from: file.path.clone(),
to: to.clone(),
kind: edge.kind,
});
included_by
.entry(to.clone())
.or_default()
.push(file.path.clone());
}
_ => unresolved.push(UnresolvedInclude {
from: file.path.clone(),
target: edge.target.clone(),
kind: edge.kind,
}),
}
}
edges.insert(file.path.clone(), outgoing);
}
let adj: HashMap<PathBuf, Vec<PathBuf>> = edges
.iter()
.map(|(from, outgoing)| {
(
from.clone(),
outgoing.iter().map(|e| e.to.clone()).collect(),
)
})
.collect();
let reachable = match root {
Some(root) => reachable_over(root, &adj),
None => HashSet::new(),
};
let roots: Vec<PathBuf> = files.iter().map(|f| f.path.clone()).collect();
let cycles = detect_cycles_over(&roots, &adj);
Self {
edges,
included_by,
unresolved,
reachable,
cycles,
}
}
pub fn outgoing(&self, file: &Path) -> &[ResolvedInclude] {
self.edges.get(file).map_or(&[], Vec::as_slice)
}
pub fn included_by(&self, file: &Path) -> &[PathBuf] {
self.included_by.get(file).map_or(&[], Vec::as_slice)
}
pub fn is_reachable(&self, file: &Path) -> bool {
self.reachable.contains(file)
}
pub fn unresolved(&self) -> &[UnresolvedInclude] {
&self.unresolved
}
pub fn cycles(&self) -> &[Vec<PathBuf>] {
&self.cycles
}
}
fn reachable_over(root: &Path, adj: &HashMap<PathBuf, Vec<PathBuf>>) -> HashSet<PathBuf> {
let mut seen: HashSet<PathBuf> = HashSet::new();
let mut stack = vec![root.to_path_buf()];
while let Some(path) = stack.pop() {
if !seen.insert(path.clone()) {
continue;
}
for to in adj.get(&path).map_or(&[][..], Vec::as_slice) {
stack.push(to.clone());
}
}
seen
}
fn detect_cycles_over(
roots: &[PathBuf],
adj: &HashMap<PathBuf, Vec<PathBuf>>,
) -> Vec<Vec<PathBuf>> {
let mut on_stack: HashSet<PathBuf> = HashSet::new();
let mut visited: HashSet<PathBuf> = HashSet::new();
let mut path: Vec<PathBuf> = Vec::new();
let mut found: HashSet<Vec<PathBuf>> = HashSet::new();
for root in roots {
dfs_cycles(
root,
adj,
&mut on_stack,
&mut visited,
&mut path,
&mut found,
);
}
found.into_iter().collect()
}
fn dfs_cycles(
node: &Path,
adj: &HashMap<PathBuf, Vec<PathBuf>>,
on_stack: &mut HashSet<PathBuf>,
visited: &mut HashSet<PathBuf>,
path: &mut Vec<PathBuf>,
found: &mut HashSet<Vec<PathBuf>>,
) {
if on_stack.contains(node) {
if let Some(start) = path.iter().position(|p| p == node) {
found.insert(normalize_cycle(&path[start..]));
}
return;
}
if !visited.insert(node.to_path_buf()) {
return;
}
on_stack.insert(node.to_path_buf());
path.push(node.to_path_buf());
for to in adj.get(node).map_or(&[][..], Vec::as_slice) {
dfs_cycles(to, adj, on_stack, visited, path, found);
}
path.pop();
on_stack.remove(node);
}
fn normalize_cycle(cycle: &[PathBuf]) -> Vec<PathBuf> {
let Some(min_at) = (0..cycle.len()).min_by_key(|&i| &cycle[i]) else {
return Vec::new();
};
cycle[min_at..]
.iter()
.chain(&cycle[..min_at])
.cloned()
.collect()
}
#[derive(Debug, Clone)]
pub struct PackageFileFacts {
pub path: PathBuf,
pub package_edges: Vec<PackageEdgeKey>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResolvedLoad {
pub from: PathBuf,
pub to: PathBuf,
pub kind: PackageKind,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UnresolvedLoad {
pub from: PathBuf,
pub target: PackageTarget,
pub kind: PackageKind,
}
#[derive(Debug, Default)]
pub struct PackageGraph {
loads: HashMap<PathBuf, Vec<ResolvedLoad>>,
loaded_by: HashMap<PathBuf, Vec<PathBuf>>,
unresolved: Vec<UnresolvedLoad>,
cycles: Vec<Vec<PathBuf>>,
}
impl PackageGraph {
pub fn build(files: &[PackageFileFacts]) -> Self {
let members: HashSet<&Path> = files.iter().map(|f| f.path.as_path()).collect();
let mut loads: HashMap<PathBuf, Vec<ResolvedLoad>> = HashMap::new();
let mut loaded_by: HashMap<PathBuf, Vec<PathBuf>> = HashMap::new();
let mut unresolved: Vec<UnresolvedLoad> = Vec::new();
for file in files {
let mut outgoing = Vec::new();
for edge in &file.package_edges {
match &edge.target {
PackageTarget::Path(to) if members.contains(to.as_path()) => {
outgoing.push(ResolvedLoad {
from: file.path.clone(),
to: to.clone(),
kind: edge.kind,
});
loaded_by
.entry(to.clone())
.or_default()
.push(file.path.clone());
}
_ => unresolved.push(UnresolvedLoad {
from: file.path.clone(),
target: edge.target.clone(),
kind: edge.kind,
}),
}
}
loads.insert(file.path.clone(), outgoing);
}
let adj: HashMap<PathBuf, Vec<PathBuf>> = loads
.iter()
.map(|(from, outgoing)| {
(
from.clone(),
outgoing.iter().map(|e| e.to.clone()).collect(),
)
})
.collect();
let roots: Vec<PathBuf> = files.iter().map(|f| f.path.clone()).collect();
let cycles = detect_cycles_over(&roots, &adj);
Self {
loads,
loaded_by,
unresolved,
cycles,
}
}
pub fn loads(&self, file: &Path) -> &[ResolvedLoad] {
self.loads.get(file).map_or(&[], Vec::as_slice)
}
pub fn loaded_by(&self, file: &Path) -> &[PathBuf] {
self.loaded_by.get(file).map_or(&[], Vec::as_slice)
}
pub fn unresolved(&self) -> &[UnresolvedLoad] {
&self.unresolved
}
pub fn cycles(&self) -> &[Vec<PathBuf>] {
&self.cycles
}
pub fn transitively_loaded(&self, start: &Path) -> Vec<PathBuf> {
let mut order = Vec::new();
let mut visited: HashSet<PathBuf> = HashSet::new();
visited.insert(start.to_path_buf());
self.collect_loaded(start, &mut visited, &mut order);
order
}
fn collect_loaded(
&self,
node: &Path,
visited: &mut HashSet<PathBuf>,
order: &mut Vec<PathBuf>,
) {
for load in self.loads(node) {
if visited.insert(load.to.clone()) {
self.collect_loaded(&load.to, visited, order);
order.push(load.to.clone());
}
}
}
}
#[derive(Clone, PartialEq, Eq, Hash)]
pub struct ProjectMember {
pub file: SourceFile,
pub path: PathBuf,
pub kind: FileKind,
}
#[salsa::interned]
pub struct Project<'db> {
#[returns(ref)]
pub members: Vec<ProjectMember>,
}
#[salsa::tracked(returns(ref), no_eq, unsafe(non_update_types))]
pub fn project_graph<'db>(db: &'db dyn IncrementalDb, project: Project<'db>) -> IncludeGraph {
db.record_query(QueryLogEntry {
kind: QueryKind::ProjectGraph,
file: None,
});
let facts: Vec<FileFacts> = project
.members(db)
.iter()
.filter(|member| member.kind.is_latex())
.map(|member| FileFacts {
path: member.path.clone(),
include_edges: include_edges(db, member.file).clone(),
})
.collect();
IncludeGraph::build(&facts, None)
}
#[salsa::tracked(returns(ref), no_eq, unsafe(non_update_types))]
pub fn package_graph<'db>(db: &'db dyn IncrementalDb, project: Project<'db>) -> PackageGraph {
db.record_query(QueryLogEntry {
kind: QueryKind::PackageGraph,
file: None,
});
let facts: Vec<PackageFileFacts> = project
.members(db)
.iter()
.filter(|member| member.kind.is_latex())
.map(|member| PackageFileFacts {
path: member.path.clone(),
package_edges: package_edges(db, member.file).clone(),
})
.collect();
PackageGraph::build(&facts)
}
#[cfg(test)]
mod tests {
use super::*;
fn facts(path: &str, edges: &[(IncludeKind, &str)]) -> FileFacts {
FileFacts {
path: PathBuf::from(path),
include_edges: edges
.iter()
.map(|(kind, target)| IncludeEdgeKey {
kind: *kind,
target: IncludeTarget::Path(PathBuf::from(target)),
})
.collect(),
}
}
#[test]
fn resolves_edges_within_the_member_set() {
let files = vec![
facts("/p/main.tex", &[(IncludeKind::Input, "/p/a.tex")]),
facts("/p/a.tex", &[]),
];
let g = IncludeGraph::build(&files, None);
assert_eq!(
g.outgoing(Path::new("/p/main.tex")),
&[ResolvedInclude {
from: PathBuf::from("/p/main.tex"),
to: PathBuf::from("/p/a.tex"),
kind: IncludeKind::Input,
}]
);
assert_eq!(
g.included_by(Path::new("/p/a.tex")),
&[PathBuf::from("/p/main.tex")]
);
assert!(g.unresolved().is_empty());
}
#[test]
fn target_outside_member_set_is_unresolved() {
let files = vec![facts(
"/p/main.tex",
&[(IncludeKind::Input, "/p/missing.tex")],
)];
let g = IncludeGraph::build(&files, None);
assert!(g.outgoing(Path::new("/p/main.tex")).is_empty());
assert_eq!(g.unresolved().len(), 1);
assert_eq!(g.unresolved()[0].from, PathBuf::from("/p/main.tex"));
}
#[test]
fn dynamic_target_is_unresolved() {
let files = vec![FileFacts {
path: PathBuf::from("/p/main.tex"),
include_edges: vec![IncludeEdgeKey {
kind: IncludeKind::Input,
target: IncludeTarget::Dynamic,
}],
}];
let g = IncludeGraph::build(&files, None);
assert_eq!(g.unresolved().len(), 1);
assert_eq!(g.unresolved()[0].target, IncludeTarget::Dynamic);
}
#[test]
fn reachability_follows_transitive_edges_from_root() {
let files = vec![
facts("/p/main.tex", &[(IncludeKind::Input, "/p/a.tex")]),
facts("/p/a.tex", &[(IncludeKind::Input, "/p/b.tex")]),
facts("/p/b.tex", &[]),
facts("/p/orphan.tex", &[]),
];
let g = IncludeGraph::build(&files, Some(Path::new("/p/main.tex")));
assert!(g.is_reachable(Path::new("/p/main.tex")));
assert!(g.is_reachable(Path::new("/p/a.tex")));
assert!(g.is_reachable(Path::new("/p/b.tex")));
assert!(!g.is_reachable(Path::new("/p/orphan.tex")));
}
#[test]
fn no_root_means_nothing_reachable() {
let files = vec![facts("/p/main.tex", &[])];
let g = IncludeGraph::build(&files, None);
assert!(!g.is_reachable(Path::new("/p/main.tex")));
}
#[test]
fn detects_a_cycle() {
let files = vec![
facts("/p/a.tex", &[(IncludeKind::Input, "/p/b.tex")]),
facts("/p/b.tex", &[(IncludeKind::Input, "/p/a.tex")]),
];
let g = IncludeGraph::build(&files, None);
assert_eq!(g.cycles().len(), 1);
assert_eq!(
g.cycles()[0],
vec![PathBuf::from("/p/a.tex"), PathBuf::from("/p/b.tex")]
);
}
#[test]
fn self_inclusion_is_a_cycle() {
let files = vec![facts("/p/a.tex", &[(IncludeKind::Input, "/p/a.tex")])];
let g = IncludeGraph::build(&files, None);
assert_eq!(g.cycles(), &[vec![PathBuf::from("/p/a.tex")]]);
}
#[test]
fn acyclic_graph_has_no_cycles() {
let files = vec![
facts("/p/main.tex", &[(IncludeKind::Input, "/p/a.tex")]),
facts("/p/a.tex", &[]),
];
let g = IncludeGraph::build(&files, None);
assert!(g.cycles().is_empty());
}
fn pkg_facts(path: &str, edges: &[(PackageKind, &str)]) -> PackageFileFacts {
PackageFileFacts {
path: PathBuf::from(path),
package_edges: edges
.iter()
.map(|(kind, target)| PackageEdgeKey {
kind: *kind,
target: PackageTarget::Path(PathBuf::from(target)),
})
.collect(),
}
}
#[test]
fn package_graph_resolves_a_local_load() {
let files = vec![
pkg_facts("/p/main.tex", &[(PackageKind::UsePackage, "/p/mypkg.sty")]),
pkg_facts("/p/mypkg.sty", &[]),
];
let g = PackageGraph::build(&files);
assert_eq!(
g.loads(Path::new("/p/main.tex")),
&[ResolvedLoad {
from: PathBuf::from("/p/main.tex"),
to: PathBuf::from("/p/mypkg.sty"),
kind: PackageKind::UsePackage,
}]
);
assert_eq!(
g.loaded_by(Path::new("/p/mypkg.sty")),
&[PathBuf::from("/p/main.tex")]
);
assert!(g.unresolved().is_empty());
}
#[test]
fn non_local_package_is_unresolved() {
let files = vec![pkg_facts(
"/p/main.tex",
&[(PackageKind::UsePackage, "/p/amsmath.sty")],
)];
let g = PackageGraph::build(&files);
assert!(g.loads(Path::new("/p/main.tex")).is_empty());
assert_eq!(g.unresolved().len(), 1);
}
#[test]
fn transitively_loaded_is_post_order() {
let files = vec![
pkg_facts(
"/p/main.tex",
&[
(PackageKind::UsePackage, "/p/a.sty"),
(PackageKind::UsePackage, "/p/c.sty"),
],
),
pkg_facts("/p/a.sty", &[(PackageKind::RequirePackage, "/p/b.sty")]),
pkg_facts("/p/b.sty", &[]),
pkg_facts("/p/c.sty", &[]),
];
let g = PackageGraph::build(&files);
assert_eq!(
g.transitively_loaded(Path::new("/p/main.tex")),
vec![
PathBuf::from("/p/b.sty"),
PathBuf::from("/p/a.sty"),
PathBuf::from("/p/c.sty"),
]
);
}
#[test]
fn mutual_requirepackage_is_a_cycle() {
let files = vec![
pkg_facts("/p/a.sty", &[(PackageKind::RequirePackage, "/p/b.sty")]),
pkg_facts("/p/b.sty", &[(PackageKind::RequirePackage, "/p/a.sty")]),
];
let g = PackageGraph::build(&files);
assert_eq!(g.cycles().len(), 1);
assert_eq!(
g.transitively_loaded(Path::new("/p/a.sty")),
vec![PathBuf::from("/p/b.sty")]
);
}
}