use std::collections::{BTreeSet, HashMap, HashSet};
use std::path::{Path, PathBuf};
use crate::project::source::{SourceEdgeKey, SourceTarget};
use crate::rindex::harvest::parse_namespace;
static EMPTY: BTreeSet<String> = BTreeSet::new();
#[derive(Debug, Clone)]
pub struct FileFacts {
pub path: PathBuf,
pub exports: BTreeSet<String>,
pub free_reads: BTreeSet<String>,
pub source_edges: Vec<SourceEdgeKey>,
pub package_root: Option<PathBuf>,
}
#[derive(Debug, Default)]
pub struct ProjectScope {
visible: HashMap<PathBuf, BTreeSet<String>>,
used_by_others: HashMap<PathBuf, BTreeSet<String>>,
dynamic: HashSet<PathBuf>,
}
pub struct FileScope<'a> {
visible: &'a BTreeSet<String>,
used_by_others: &'a BTreeSet<String>,
pub resolution_incomplete: bool,
}
impl<'a> FileScope<'a> {
pub fn new(
visible: &'a BTreeSet<String>,
used_by_others: &'a BTreeSet<String>,
resolution_incomplete: bool,
) -> Self {
Self {
visible,
used_by_others,
resolution_incomplete,
}
}
pub fn visible_names(&self) -> &BTreeSet<String> {
self.visible
}
pub fn used_names(&self) -> &BTreeSet<String> {
self.used_by_others
}
pub fn resolves(&self, name: &str) -> bool {
self.visible.contains(name)
}
pub fn used_elsewhere(&self, name: &str) -> bool {
self.used_by_others.contains(name)
}
}
impl ProjectScope {
pub fn build(files: &[FileFacts], namespaces: &HashMap<PathBuf, String>) -> Self {
let by_path: HashMap<&Path, &FileFacts> =
files.iter().map(|f| (f.path.as_path(), f)).collect();
let mut package_members: HashMap<&Path, Vec<&Path>> = HashMap::new();
for f in files {
if let Some(root) = &f.package_root {
package_members
.entry(root.as_path())
.or_default()
.push(f.path.as_path());
}
}
let mut sees: HashMap<PathBuf, HashSet<PathBuf>> = HashMap::new();
let mut dynamic: HashSet<PathBuf> = HashSet::new();
for f in files {
let mut seen: HashSet<PathBuf> = HashSet::new();
if let Some(root) = &f.package_root {
for member in &package_members[root.as_path()] {
if *member != f.path {
seen.insert(member.to_path_buf());
}
}
}
let mut unresolved = false;
let mut visited: HashSet<&Path> = HashSet::from([f.path.as_path()]);
let mut queue: Vec<&FileFacts> = vec![f];
while let Some(cur) = queue.pop() {
for edge in &cur.source_edges {
match source_dependency(edge) {
Dependency::Skip => {}
Dependency::Unresolved => unresolved = true,
Dependency::Path(p) => match by_path.get(p) {
Some(target) if visited.insert(target.path.as_path()) => {
seen.insert(target.path.clone());
queue.push(target);
}
Some(_) => {}
None => unresolved = true,
},
}
}
}
if unresolved {
dynamic.insert(f.path.clone());
}
sees.insert(f.path.clone(), seen);
}
let mut visible: HashMap<PathBuf, BTreeSet<String>> = HashMap::new();
let mut used_by_others: HashMap<PathBuf, BTreeSet<String>> = files
.iter()
.map(|f| (f.path.clone(), BTreeSet::new()))
.collect();
for f in files {
let mut defs = BTreeSet::new();
for seen in &sees[&f.path] {
if let Some(target) = by_path.get(seen.as_path()) {
defs.extend(target.exports.iter().cloned());
}
}
for name in &f.exports {
defs.remove(name);
}
visible.insert(f.path.clone(), defs);
for seen in &sees[&f.path] {
if let Some(used) = used_by_others.get_mut(seen) {
used.extend(f.free_reads.iter().cloned());
}
}
}
for (root, text) in namespaces {
let Some(members) = package_members.get(root.as_path()) else {
continue;
};
let object_names: Vec<String> = members
.iter()
.filter_map(|m| by_path.get(m))
.flat_map(|f| f.exports.iter().map(|n| n.to_string()))
.collect();
let info = parse_namespace(text, &object_names);
let exported: BTreeSet<String> = info.exports.iter().cloned().collect();
let imported: BTreeSet<String> = info.imported_names.iter().cloned().collect();
let incomplete = !info.imported_packages.is_empty();
for member in members {
let path = member.to_path_buf();
if let Some(used) = used_by_others.get_mut(&path) {
used.extend(exported.iter().cloned());
}
if let Some(vis) = visible.get_mut(&path) {
vis.extend(imported.iter().cloned());
}
if incomplete {
dynamic.insert(path);
}
}
}
Self {
visible,
used_by_others,
dynamic,
}
}
pub fn for_file(&self, path: &Path) -> FileScope<'_> {
FileScope {
visible: self.visible.get(path).unwrap_or(&EMPTY),
used_by_others: self.used_by_others.get(path).unwrap_or(&EMPTY),
resolution_incomplete: self.dynamic.contains(path),
}
}
}
enum Dependency<'a> {
Path(&'a Path),
Unresolved,
Skip,
}
fn source_dependency(edge: &SourceEdgeKey) -> Dependency<'_> {
match &edge.target {
SourceTarget::Dynamic => Dependency::Unresolved,
SourceTarget::Path(_) if edge.local => Dependency::Skip,
SourceTarget::Path(p) => Dependency::Path(p.as_path()),
}
}
pub fn package_root(path: &Path) -> Option<PathBuf> {
let mut dir = path.parent();
while let Some(d) = dir {
if d.join("DESCRIPTION").is_file() && d.join("R").is_dir() {
return Some(d.to_path_buf());
}
dir = d.parent();
}
None
}
#[cfg(test)]
mod tests {
use super::*;
fn set(names: &[&str]) -> BTreeSet<String> {
names.iter().map(|n| n.to_string()).collect()
}
fn source_path(target: &str, local: bool) -> SourceEdgeKey {
SourceEdgeKey {
target: SourceTarget::Path(PathBuf::from(target)),
local,
}
}
fn dynamic_edge() -> SourceEdgeKey {
SourceEdgeKey {
target: SourceTarget::Dynamic,
local: false,
}
}
fn facts(
path: &str,
exp: &[&str],
reads: &[&str],
edges: Vec<SourceEdgeKey>,
root: Option<&str>,
) -> FileFacts {
FileFacts {
path: PathBuf::from(path),
exports: set(exp),
free_reads: set(reads),
source_edges: edges,
package_root: root.map(PathBuf::from),
}
}
fn names(set: &BTreeSet<String>) -> Vec<String> {
let mut v: Vec<String> = set.iter().map(|s| s.to_string()).collect();
v.sort();
v
}
fn build_scope(files: &[FileFacts]) -> ProjectScope {
ProjectScope::build(files, &HashMap::new())
}
#[test]
fn package_files_share_one_namespace() {
let files = [
facts("/pkg/R/a.R", &["foo"], &[], vec![], Some("/pkg")),
facts("/pkg/R/b.R", &["bar"], &["foo"], vec![], Some("/pkg")),
];
let scope = build_scope(&files);
assert!(scope.for_file(Path::new("/pkg/R/b.R")).resolves("foo"));
assert!(
scope
.for_file(Path::new("/pkg/R/a.R"))
.used_elsewhere("foo")
);
assert!(
!scope
.for_file(Path::new("/pkg/R/b.R"))
.used_elsewhere("bar")
);
}
#[test]
fn source_closure_is_directional() {
let files = [
facts(
"/s/a.R",
&["foo"],
&["bar"],
vec![source_path("/s/b.R", false)],
None,
),
facts("/s/b.R", &["bar"], &[], vec![], None),
];
let scope = build_scope(&files);
assert!(scope.for_file(Path::new("/s/a.R")).resolves("bar"));
assert!(!scope.for_file(Path::new("/s/b.R")).resolves("foo"));
assert!(scope.for_file(Path::new("/s/b.R")).used_elsewhere("bar"));
assert!(!scope.for_file(Path::new("/s/a.R")).resolution_incomplete);
}
#[test]
fn source_closure_is_transitive_and_cycle_safe() {
let files = [
facts(
"/s/a.R",
&["foo"],
&[],
vec![source_path("/s/b.R", false)],
None,
),
facts(
"/s/b.R",
&["bar"],
&[],
vec![source_path("/s/c.R", false)],
None,
),
facts(
"/s/c.R",
&["baz"],
&[],
vec![source_path("/s/a.R", false)],
None,
),
];
let scope = build_scope(&files);
assert_eq!(
names(scope.for_file(Path::new("/s/a.R")).visible),
vec!["bar", "baz"]
);
}
#[test]
fn dynamic_source_marks_scope_incomplete() {
let files = [facts("/s/a.R", &[], &[], vec![dynamic_edge()], None)];
let scope = build_scope(&files);
assert!(scope.for_file(Path::new("/s/a.R")).resolution_incomplete);
}
#[test]
fn source_to_unanalyzed_file_marks_scope_incomplete() {
let files = [facts(
"/s/a.R",
&[],
&[],
vec![source_path("/s/missing.R", false)],
None,
)];
let scope = build_scope(&files);
assert!(scope.for_file(Path::new("/s/a.R")).resolution_incomplete);
}
#[test]
fn local_source_neither_contributes_nor_marks_dynamic() {
let files = [
facts(
"/s/a.R",
&[],
&["bar"],
vec![source_path("/s/b.R", true)],
None,
),
facts("/s/b.R", &["bar"], &[], vec![], None),
];
let scope = build_scope(&files);
let a = scope.for_file(Path::new("/s/a.R"));
assert!(!a.resolves("bar"));
assert!(!a.resolution_incomplete);
assert!(!scope.for_file(Path::new("/s/b.R")).used_elsewhere("bar"));
}
fn namespaces(entries: &[(&str, &str)]) -> HashMap<PathBuf, String> {
entries
.iter()
.map(|(root, text)| (PathBuf::from(*root), text.to_string()))
.collect()
}
#[test]
fn namespace_export_marks_binding_used() {
let files = [facts("/pkg/R/a.R", &["foo"], &[], vec![], Some("/pkg"))];
let ns = namespaces(&[("/pkg", "export(foo)\n")]);
let scope = ProjectScope::build(&files, &ns);
assert!(
scope
.for_file(Path::new("/pkg/R/a.R"))
.used_elsewhere("foo")
);
}
#[test]
fn namespace_import_from_resolves_name() {
let files = [facts("/pkg/R/a.R", &[], &["filter"], vec![], Some("/pkg"))];
let ns = namespaces(&[("/pkg", "importFrom(dplyr, filter)\n")]);
let scope = ProjectScope::build(&files, &ns);
let a = scope.for_file(Path::new("/pkg/R/a.R"));
assert!(a.resolves("filter"));
assert!(!a.resolution_incomplete);
}
#[test]
fn namespace_wholesale_import_marks_resolution_incomplete() {
let files = [facts("/pkg/R/a.R", &[], &["abort"], vec![], Some("/pkg"))];
let ns = namespaces(&[("/pkg", "import(rlang)\n")]);
let scope = ProjectScope::build(&files, &ns);
assert!(
scope
.for_file(Path::new("/pkg/R/a.R"))
.resolution_incomplete
);
}
}