use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use crate::file_discovery::FileKind;
use crate::incremental::{IncrementalDb, QueryKind, QueryLogEntry, SourceFile, include_edges};
use crate::project::include::{IncludeEdgeKey, IncludeKind, IncludeTarget};
#[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 reachable = match root {
Some(root) => reachable_from(root, &edges),
None => HashSet::new(),
};
let cycles = detect_cycles(files, &edges);
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_from(root: &Path, edges: &HashMap<PathBuf, Vec<ResolvedInclude>>) -> 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 edge in edges.get(&path).map_or(&[][..], Vec::as_slice) {
stack.push(edge.to.clone());
}
}
seen
}
fn detect_cycles(
files: &[FileFacts],
edges: &HashMap<PathBuf, Vec<ResolvedInclude>>,
) -> 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 file in files {
dfs_cycles(
&file.path,
edges,
&mut on_stack,
&mut visited,
&mut path,
&mut found,
);
}
found.into_iter().collect()
}
fn dfs_cycles(
node: &Path,
edges: &HashMap<PathBuf, Vec<ResolvedInclude>>,
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 edge in edges.get(node).map_or(&[][..], Vec::as_slice) {
dfs_cycles(&edge.to, edges, 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(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)
}
#[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());
}
}