use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::path::{Path, PathBuf};
use rowan::TextRange;
use smol_str::SmolStr;
use crate::incremental::{
IncrementalDb, LibraryIndex, PackageGraph, QueryKind, QueryLogEntry, SourceFile, Workspace,
file_def_sites, file_exports, file_free_reads, loaded_names, parse_diagnostics, source_edges,
top_level_events,
};
use crate::project::exports::DefKind;
use crate::project::scope::{FileFacts, FileScope, ProjectScope, package_root};
use crate::project::source::{SourceEdgeKey, SourceTarget};
use crate::rindex::harvest::parse_dcf;
use crate::rindex::provider::{package_indexed, resolve_origin};
use crate::semantic::symbols::{LoadedPackage, PackageOrigin};
#[derive(Clone, PartialEq, Eq, Hash)]
pub struct ProjectMember {
pub file: SourceFile,
pub path: PathBuf,
pub package_root: Option<PathBuf>,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct PackageCollation {
pub root: PathBuf,
pub complete: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, salsa::Update)]
pub struct PackageInfo {
pub root: PathBuf,
pub namespace: Option<String>,
pub expected_sources: BTreeSet<String>,
}
#[salsa::interned]
pub struct Project<'db> {
#[returns(ref)]
pub members: Vec<ProjectMember>,
#[returns(ref)]
pub namespaces: Vec<(PathBuf, String)>,
#[returns(ref)]
pub collations: Vec<PackageCollation>,
}
#[derive(Debug, Default, Clone, PartialEq, Eq, salsa::Update)]
pub struct Visibility {
pub visible: BTreeSet<String>,
pub used_by_others: BTreeSet<String>,
pub incomplete: bool,
}
impl Visibility {
pub fn scope(&self) -> FileScope<'_> {
FileScope::new(&self.visible, &self.used_by_others, self.incomplete)
}
}
pub fn discover_packages(member_paths: &[PathBuf]) -> Vec<PackageInfo> {
let roots: BTreeSet<PathBuf> = member_paths
.iter()
.filter_map(|p| package_root(p))
.collect();
roots
.into_iter()
.map(|root| PackageInfo {
namespace: std::fs::read_to_string(root.join("NAMESPACE")).ok(),
expected_sources: expected_r_sources(&root),
root,
})
.collect()
}
fn package_root_in(path: &Path, roots: &BTreeSet<&PathBuf>) -> Option<PathBuf> {
let mut dir = path.parent();
while let Some(d) = dir {
if roots.contains(&d.to_path_buf()) {
return Some(d.to_path_buf());
}
dir = d.parent();
}
None
}
const R_SOURCE_EXTS: [&str; 6] = ["R", "r", "S", "s", "Q", "q"];
fn expected_r_sources(root: &Path) -> BTreeSet<String> {
let mut expected: BTreeSet<String> = BTreeSet::new();
if let Ok(entries) = std::fs::read_dir(root.join("R")) {
for entry in entries.flatten() {
let path = entry.path();
if path
.extension()
.and_then(|e| e.to_str())
.is_some_and(|e| R_SOURCE_EXTS.contains(&e))
&& path.is_file()
&& let Some(name) = path.file_name().and_then(|n| n.to_str())
{
expected.insert(name.to_string());
}
}
}
if let Ok(text) = std::fs::read_to_string(root.join("DESCRIPTION")) {
for (key, value) in parse_dcf(&text) {
if key.starts_with("Collate") {
for entry in value.split_whitespace() {
let name = entry.trim_matches(['\'', '"']);
if !name.is_empty() {
expected.insert(name.to_string());
}
}
}
}
}
expected
}
#[salsa::tracked]
pub fn workspace_project<'db>(db: &'db dyn IncrementalDb) -> Project<'db> {
db.record_query(QueryLogEntry {
kind: QueryKind::WorkspaceProject,
file: None,
});
let packages = PackageGraph::try_get(db);
let infos: &[PackageInfo] = packages.as_ref().map_or(&[], |g| g.packages(db));
let roots: BTreeSet<&PathBuf> = infos.iter().map(|p| &p.root).collect();
let mut members: Vec<ProjectMember> = match Workspace::try_get(db) {
Some(ws) => ws
.members(db)
.iter()
.filter_map(|&file| {
let path = file.path(db).as_deref()?.to_path_buf();
if !parse_diagnostics(db, file).is_empty() {
return None;
}
let package_root = package_root_in(&path, &roots);
Some(ProjectMember {
file,
path,
package_root,
})
})
.collect(),
None => Vec::new(),
};
members.sort_by(|a, b| a.path.cmp(&b.path));
let by_root: HashMap<&PathBuf, &PackageInfo> = infos.iter().map(|p| (&p.root, p)).collect();
let mut analyzed: BTreeMap<PathBuf, BTreeSet<String>> = BTreeMap::new();
for member in &members {
if let Some(root) = &member.package_root
&& let Some(name) = member.path.file_name().and_then(|n| n.to_str())
{
analyzed
.entry(root.clone())
.or_default()
.insert(name.to_string());
}
}
let mut namespaces: Vec<(PathBuf, String)> = analyzed
.keys()
.filter_map(|root| {
by_root
.get(root)
.and_then(|info| info.namespace.clone())
.map(|text| (root.clone(), text))
})
.collect();
namespaces.sort_by(|a, b| a.0.cmp(&b.0));
let collations: Vec<PackageCollation> = analyzed
.into_iter()
.map(|(root, analyzed_names)| {
let complete = by_root.get(&root).is_some_and(|info| {
info.expected_sources
.iter()
.all(|name| analyzed_names.contains(name))
});
PackageCollation { root, complete }
})
.collect();
Project::new(db, members, namespaces, collations)
}
#[salsa::tracked(returns(ref), no_eq, unsafe(non_update_types))]
pub fn project_graph<'db>(db: &'db dyn IncrementalDb, project: Project<'db>) -> ProjectScope {
db.record_query(QueryLogEntry {
kind: QueryKind::ProjectGraph,
file: None,
});
let facts: Vec<FileFacts> = project
.members(db)
.iter()
.map(|m| FileFacts {
path: m.path.clone(),
exports: file_exports(db, m.file).clone(),
free_reads: file_free_reads(db, m.file).clone(),
source_edges: source_edges(db, m.file).clone(),
top_level_events: top_level_events(db, m.file).clone(),
package_root: m.package_root.clone(),
})
.collect();
let namespaces: HashMap<PathBuf, String> = project.namespaces(db).iter().cloned().collect();
let package_complete: HashMap<PathBuf, bool> = project
.collations(db)
.iter()
.map(|c| (c.root.clone(), c.complete))
.collect();
ProjectScope::build(&facts, &namespaces, &package_complete)
}
#[salsa::tracked(returns(ref))]
pub fn visible_symbols<'db>(
db: &'db dyn IncrementalDb,
project: Project<'db>,
file: SourceFile,
) -> Visibility {
db.record_query(QueryLogEntry {
kind: QueryKind::VisibleSymbols,
file: Some(file),
});
let graph = project_graph(db, project);
let Some(path) = file.path(db).as_deref() else {
return Visibility::default();
};
let scope = graph.for_file(path);
Visibility {
visible: scope.visible_names().clone(),
used_by_others: scope.used_names().clone(),
incomplete: scope.resolution_incomplete,
}
}
#[derive(Debug, Default, Clone, PartialEq, Eq, salsa::Update)]
pub struct ReverseSources {
pub sourced_by: BTreeMap<PathBuf, BTreeSet<PathBuf>>,
pub dynamic_sources: BTreeSet<PathBuf>,
}
fn invert_source_edges<'a>(
members: impl IntoIterator<Item = (&'a Path, &'a [SourceEdgeKey])>,
) -> ReverseSources {
let mut rev = ReverseSources::default();
for (path, edges) in members {
for edge in edges {
match &edge.target {
SourceTarget::Dynamic => {
rev.dynamic_sources.insert(path.to_path_buf());
}
SourceTarget::Path(target) => {
rev.sourced_by
.entry(target.clone())
.or_default()
.insert(path.to_path_buf());
}
}
}
}
rev
}
#[salsa::tracked(returns(ref))]
pub fn reverse_source_edges<'db>(
db: &'db dyn IncrementalDb,
project: Project<'db>,
) -> ReverseSources {
db.record_query(QueryLogEntry {
kind: QueryKind::ReverseSourceEdges,
file: None,
});
invert_source_edges(
project
.members(db)
.iter()
.map(|m| (m.path.as_path(), source_edges(db, m.file).as_slice())),
)
}
#[derive(Debug, Default, Clone, PartialEq, Eq, salsa::Update)]
pub struct DefIndex {
pub by_name: BTreeMap<String, BTreeSet<(PathBuf, DefKind)>>,
}
#[salsa::tracked(returns(ref))]
pub fn project_defs<'db>(db: &'db dyn IncrementalDb, project: Project<'db>) -> DefIndex {
db.record_query(QueryLogEntry {
kind: QueryKind::ProjectDefs,
file: None,
});
let mut index = DefIndex::default();
for member in project.members(db) {
for (name, kind) in file_def_sites(db, member.file) {
index
.by_name
.entry(name.clone())
.or_default()
.insert((member.path.clone(), *kind));
}
}
index
}
#[derive(Debug, Default, Clone, PartialEq, Eq, salsa::Update)]
pub struct ReadIndex {
pub by_name: BTreeMap<String, BTreeSet<PathBuf>>,
}
#[salsa::tracked(returns(ref))]
pub fn project_reads<'db>(db: &'db dyn IncrementalDb, project: Project<'db>) -> ReadIndex {
db.record_query(QueryLogEntry {
kind: QueryKind::ProjectReads,
file: None,
});
let mut index = ReadIndex::default();
for member in project.members(db) {
for name in file_free_reads(db, member.file) {
index
.by_name
.entry(name.clone())
.or_default()
.insert(member.path.clone());
}
}
index
}
#[derive(Debug, Default, Clone, PartialEq, Eq, salsa::Update)]
pub struct ExternalResolution {
pub unresolved: BTreeSet<String>,
}
#[salsa::tracked(returns(ref))]
pub fn external_resolution<'db>(
db: &'db dyn IncrementalDb,
manifest: LibraryIndex,
project: Project<'db>,
file: SourceFile,
) -> ExternalResolution {
db.record_query(QueryLogEntry {
kind: QueryKind::ExternalResolution,
file: Some(file),
});
let index: &crate::rindex::provider::IndexedProvider = manifest.data(db);
let remote: &crate::rindex::remote::RemoteExports = manifest.remote(db);
let loaded = loaded_names(db, file);
if loaded
.iter()
.any(|pkg| !package_indexed(index, remote, pkg))
{
return ExternalResolution::default();
}
let visibility = visible_symbols(db, project, file);
if visibility.incomplete {
return ExternalResolution::default();
}
let loaded_pkgs: Vec<LoadedPackage> = loaded
.iter()
.map(|name| LoadedPackage {
name: SmolStr::new(name),
range: TextRange::default(),
})
.collect();
let unresolved = file_free_reads(db, file)
.iter()
.filter(|name| !visibility.visible.contains(name.as_str()))
.filter(|name| {
matches!(
resolve_origin(index, remote, name, &loaded_pkgs),
PackageOrigin::Unknown
)
})
.cloned()
.collect();
ExternalResolution { unresolved }
}
#[cfg(test)]
mod tests {
use super::*;
use crate::project::source::{SourceEdgeKey, SourceTarget};
fn path_edge(target: &str, local: bool) -> SourceEdgeKey {
SourceEdgeKey {
target: SourceTarget::Path(PathBuf::from(target)),
local,
}
}
fn dynamic_edge() -> SourceEdgeKey {
SourceEdgeKey {
target: SourceTarget::Dynamic,
local: false,
}
}
fn invert(members: &[(&str, Vec<SourceEdgeKey>)]) -> ReverseSources {
invert_source_edges(
members
.iter()
.map(|(p, edges)| (Path::new(*p), edges.as_slice())),
)
}
fn sourcers<'a>(rev: &'a ReverseSources, target: &str) -> Vec<&'a str> {
rev.sourced_by
.get(Path::new(target))
.into_iter()
.flat_map(|set| set.iter().map(|p| p.to_str().unwrap()))
.collect()
}
#[test]
fn single_edge_inverts() {
let rev = invert(&[
("/s/a.R", vec![path_edge("/s/b.R", false)]),
("/s/b.R", vec![]),
]);
assert_eq!(sourcers(&rev, "/s/b.R"), vec!["/s/a.R"]);
assert!(!rev.sourced_by.contains_key(Path::new("/s/a.R")));
assert!(rev.dynamic_sources.is_empty());
}
#[test]
fn multiple_sourcers_aggregate() {
let rev = invert(&[
("/s/a.R", vec![path_edge("/s/c.R", false)]),
("/s/b.R", vec![path_edge("/s/c.R", false)]),
]);
assert_eq!(sourcers(&rev, "/s/c.R"), vec!["/s/a.R", "/s/b.R"]);
}
#[test]
fn local_edge_is_retained() {
let rev = invert(&[("/s/a.R", vec![path_edge("/s/b.R", true)])]);
assert_eq!(sourcers(&rev, "/s/b.R"), vec!["/s/a.R"]);
}
#[test]
fn dynamic_edge_recorded_separately() {
let rev = invert(&[("/s/a.R", vec![dynamic_edge()])]);
assert!(rev.sourced_by.is_empty());
assert!(rev.dynamic_sources.contains(Path::new("/s/a.R")));
}
#[test]
fn target_outside_member_set_is_retained() {
let rev = invert(&[("/s/a.R", vec![path_edge("/s/gen.R", false)])]);
assert_eq!(sourcers(&rev, "/s/gen.R"), vec!["/s/a.R"]);
}
#[test]
fn expected_r_sources_unions_dir_glob_and_collate() {
let dir = tempfile::tempdir().expect("tempdir");
let root = dir.path();
std::fs::create_dir(root.join("R")).expect("R/");
std::fs::write(root.join("R/a.R"), "").expect("a.R");
std::fs::write(root.join("R/b.r"), "").expect("b.r"); std::fs::write(root.join("R/notes.md"), "").expect("notes.md"); std::fs::write(root.join("DESCRIPTION"), "Package: p\nCollate: a.R 'c.R'\n")
.expect("DESCRIPTION");
let expected = expected_r_sources(root);
assert!(expected.contains("a.R"), "dir-glob R source");
assert!(expected.contains("b.r"), "lowercase .r extension counts");
assert!(!expected.contains("notes.md"), "non-R file is excluded");
assert!(expected.contains("c.R"), "Collate entry, quote-stripped");
}
}