use std::fmt;
use std::path::{Path, PathBuf};
use ignore::WalkBuilder;
use ignore::gitignore::{Gitignore, GitignoreBuilder};
use crate::project::is_package_root;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FileDiscoveryError {
UnsupportedFilePath { path: PathBuf },
WalkError { path: PathBuf, message: String },
}
#[derive(Debug, Clone)]
pub struct ExcludeFilter {
matcher: Option<Gitignore>,
roots: Vec<PathBuf>,
force: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ExcludeError {
pub pattern: String,
pub message: String,
}
impl fmt::Display for ExcludeError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"invalid exclude pattern `{}`: {}",
self.pattern, self.message
)
}
}
impl std::error::Error for ExcludeError {}
impl ExcludeFilter {
pub fn none() -> Self {
Self {
matcher: None,
roots: Vec::new(),
force: false,
}
}
pub fn new(root: &Path, patterns: &[String]) -> Result<Self, ExcludeError> {
if patterns.is_empty() {
return Ok(Self::none());
}
let mut builder = GitignoreBuilder::new(root);
for pattern in patterns.iter().cloned() {
if let Err(err) = builder.add_line(None, &pattern) {
return Err(ExcludeError {
pattern,
message: err.to_string(),
});
}
}
let matcher = builder.build().map_err(|err| ExcludeError {
pattern: String::new(),
message: err.to_string(),
})?;
Ok(Self {
matcher: Some(matcher),
roots: root_spellings(root),
force: false,
})
}
pub fn with_force_exclude(mut self, force: bool) -> Self {
self.force = force;
self
}
pub fn force(&self) -> bool {
self.force
}
pub fn force_excludes(&self, path: &Path) -> bool {
if !self.force {
return false;
}
match &self.matcher {
Some(matcher) => {
let path = self.relativize(path);
if path.has_root() {
return false;
}
matcher.matched_path_or_any_parents(path, false).is_ignore()
}
None => false,
}
}
fn is_excluded(&self, path: &Path, is_dir: bool) -> bool {
match &self.matcher {
Some(matcher) => matcher.matched(self.relativize(path), is_dir).is_ignore(),
None => false,
}
}
fn relativize<'a>(&self, path: &'a Path) -> &'a Path {
for root in &self.roots {
if let Ok(relative) = path.strip_prefix(root) {
return relative;
}
}
path
}
}
fn root_spellings(root: &Path) -> Vec<PathBuf> {
let mut roots = vec![root.to_path_buf()];
if let Ok(canonical) = root.canonicalize() {
roots.push(canonical);
}
for index in 0..roots.len() {
if let Some(simplified) = strip_verbatim_prefix(&roots[index]) {
roots.push(simplified);
}
}
roots.dedup();
roots
}
fn strip_verbatim_prefix(path: &Path) -> Option<PathBuf> {
let rest = path.to_str()?.strip_prefix(r"\\?\")?;
match rest.strip_prefix(r"UNC\") {
Some(share) => Some(PathBuf::from(format!(r"\\{share}"))),
None => Some(PathBuf::from(rest)),
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct DiscoveredFiles {
pub r: Vec<PathBuf>,
pub description: Vec<PathBuf>,
}
pub fn collect_r_files(
paths: &[PathBuf],
exclude: &ExcludeFilter,
) -> Result<Vec<PathBuf>, FileDiscoveryError> {
collect(paths, exclude, false).map(|files| files.r)
}
pub fn collect_source_files(
paths: &[PathBuf],
exclude: &ExcludeFilter,
) -> Result<DiscoveredFiles, FileDiscoveryError> {
collect(paths, exclude, true)
}
fn collect(
paths: &[PathBuf],
exclude: &ExcludeFilter,
descriptions: bool,
) -> Result<DiscoveredFiles, FileDiscoveryError> {
let mut files = Vec::new();
let mut found_descriptions = Vec::new();
for path in paths {
if path.is_file() {
if exclude.force_excludes(path) {
continue;
}
if is_r_file(path) {
files.push(path.clone());
continue;
}
if descriptions && is_description_file(path) {
found_descriptions.push(path.clone());
continue;
}
return Err(FileDiscoveryError::UnsupportedFilePath { path: path.clone() });
}
if path.is_dir() {
let mut builder = WalkBuilder::new(path);
builder.standard_filters(true);
builder.hidden(false);
let filter = exclude.clone();
builder.filter_entry(move |entry| {
let is_dir = entry.file_type().is_some_and(|ft| ft.is_dir());
!filter.is_excluded(entry.path(), is_dir)
});
for entry in builder.build() {
match entry {
Ok(entry) => {
let entry_path = entry.path();
if !entry.file_type().is_some_and(|ft| ft.is_file()) {
continue;
}
if is_r_file(entry_path) {
files.push(entry_path.to_path_buf());
} else if descriptions
&& is_description_file(entry_path)
&& entry_path.parent().is_some_and(is_own_package_root)
{
found_descriptions.push(entry_path.to_path_buf());
}
}
Err(err) => {
return Err(FileDiscoveryError::WalkError {
path: path.clone(),
message: err.to_string(),
});
}
}
}
continue;
}
return Err(FileDiscoveryError::WalkError {
path: path.clone(),
message: "path does not exist".to_string(),
});
}
files.sort();
files.dedup();
found_descriptions.sort();
found_descriptions.dedup();
Ok(DiscoveredFiles {
r: files,
description: found_descriptions,
})
}
fn is_r_file(path: &Path) -> bool {
path.extension()
.and_then(|ext| ext.to_str())
.is_some_and(|ext| ext.eq_ignore_ascii_case("r"))
}
pub const DESCRIPTION_FILE_NAME: &str = "DESCRIPTION";
pub fn is_description_file(path: &Path) -> bool {
path.file_name()
.is_some_and(|name| name == DESCRIPTION_FILE_NAME)
}
pub(crate) fn is_own_package_root(dir: &Path) -> bool {
is_package_root(dir) && crate::project::package_root(dir).is_none()
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::tempdir;
fn touch(path: &Path) {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).unwrap();
}
fs::write(path, "x <- 1\n").unwrap();
}
fn defaults() -> Vec<String> {
crate::config::DEFAULT_EXCLUDE
.iter()
.map(|p| p.to_string())
.collect()
}
#[test]
fn excludes_default_generated_files() {
let dir = tempdir().unwrap();
let root = dir.path();
touch(&root.join("keep.R"));
touch(&root.join("RcppExports.R"));
touch(&root.join("R").join("import-standalone-types.R"));
touch(&root.join("renv").join("activate.R"));
let filter = ExcludeFilter::new(root, &defaults()).unwrap();
let files = collect_r_files(&[root.to_path_buf()], &filter).unwrap();
let names: Vec<_> = files
.iter()
.map(|p| p.strip_prefix(root).unwrap().to_string_lossy().into_owned())
.collect();
assert_eq!(names, vec!["keep.R".to_string()]);
}
#[test]
fn extra_patterns_apply_alongside_defaults() {
let dir = tempdir().unwrap();
let root = dir.path();
touch(&root.join("keep.R"));
touch(&root.join("vendor").join("thing.R"));
let mut patterns = defaults();
patterns.push("vendor/".to_string());
let filter = ExcludeFilter::new(root, &patterns).unwrap();
let files = collect_r_files(&[root.to_path_buf()], &filter).unwrap();
assert_eq!(files, vec![root.join("keep.R")]);
}
#[test]
fn empty_pattern_list_excludes_nothing() {
let dir = tempdir().unwrap();
let root = dir.path();
touch(&root.join("RcppExports.R"));
let filter = ExcludeFilter::new(root, &[]).unwrap();
let files = collect_r_files(&[root.to_path_buf()], &filter).unwrap();
assert_eq!(files, vec![root.join("RcppExports.R")]);
}
#[test]
fn explicit_file_is_not_excluded() {
let dir = tempdir().unwrap();
let root = dir.path();
let rcpp = root.join("RcppExports.R");
touch(&rcpp);
let filter = ExcludeFilter::new(root, &defaults()).unwrap();
let files = collect_r_files(std::slice::from_ref(&rcpp), &filter).unwrap();
assert_eq!(files, vec![rcpp]);
}
#[test]
fn none_filter_keeps_everything() {
let dir = tempdir().unwrap();
let root = dir.path();
touch(&root.join("keep.R"));
touch(&root.join("RcppExports.R"));
let files = collect_r_files(&[root.to_path_buf()], &ExcludeFilter::none()).unwrap();
assert_eq!(files.len(), 2);
}
#[test]
fn force_exclude_skips_explicitly_named_file() {
let dir = tempdir().unwrap();
let root = dir.path();
let rcpp = root.join("RcppExports.R");
let keep = root.join("keep.R");
touch(&rcpp);
touch(&keep);
let filter = ExcludeFilter::new(root, &defaults())
.unwrap()
.with_force_exclude(true);
let files = collect_r_files(&[rcpp, keep.clone()], &filter).unwrap();
assert_eq!(files, vec![keep]);
}
#[test]
fn force_exclude_may_leave_no_files() {
let dir = tempdir().unwrap();
let root = dir.path();
let rcpp = root.join("RcppExports.R");
touch(&rcpp);
let filter = ExcludeFilter::new(root, &defaults())
.unwrap()
.with_force_exclude(true);
let files = collect_r_files(&[rcpp], &filter).unwrap();
assert_eq!(files, Vec::<PathBuf>::new());
}
#[test]
fn force_exclude_matches_parent_directory_pattern() {
let dir = tempdir().unwrap();
let root = dir.path();
let activate = root.join("renv").join("activate.R");
touch(&activate);
let filter = ExcludeFilter::new(root, &defaults())
.unwrap()
.with_force_exclude(true);
let files = collect_r_files(&[activate], &filter).unwrap();
assert_eq!(files, Vec::<PathBuf>::new());
}
#[test]
fn force_exclude_skips_excluded_non_r_file() {
let dir = tempdir().unwrap();
let root = dir.path();
let settings = root.join("renv").join("settings.json");
touch(&settings);
let filter = ExcludeFilter::new(root, &defaults())
.unwrap()
.with_force_exclude(true);
let files = collect_r_files(&[settings], &filter).unwrap();
assert_eq!(files, Vec::<PathBuf>::new());
}
#[test]
fn force_exclude_ignores_paths_outside_matcher_root() {
let dir = tempdir().unwrap();
let other = tempdir().unwrap();
let outside = other.path().join("RcppExports.R");
touch(&outside);
let filter = ExcludeFilter::new(dir.path(), &defaults())
.unwrap()
.with_force_exclude(true);
let files = collect_r_files(std::slice::from_ref(&outside), &filter).unwrap();
assert_eq!(files, vec![outside]);
}
#[test]
fn force_exclude_ignores_rooted_paths_outside_matcher_root() {
let filter = ExcludeFilter::new(Path::new("/project"), &defaults())
.unwrap()
.with_force_exclude(true);
assert!(!filter.force_excludes(Path::new("/elsewhere/RcppExports.R")));
assert!(filter.force_excludes(Path::new("/project/RcppExports.R")));
}
#[test]
fn force_exclude_does_not_change_directory_walk() {
let dir = tempdir().unwrap();
let root = dir.path();
touch(&root.join("keep.R"));
touch(&root.join("RcppExports.R"));
touch(&root.join("renv").join("activate.R"));
let filter = ExcludeFilter::new(root, &defaults()).unwrap();
let walked = collect_r_files(&[root.to_path_buf()], &filter).unwrap();
let forced =
collect_r_files(&[root.to_path_buf()], &filter.with_force_exclude(true)).unwrap();
assert_eq!(walked, forced);
}
#[test]
#[cfg(unix)]
fn an_anchored_pattern_holds_through_a_differently_spelled_root() {
use std::os::unix::fs::symlink;
let dir = tempdir().unwrap();
let real = dir.path().join("real");
touch(&real.join("keep.R"));
touch(&real.join("tests").join("fixtures").join("skip.R"));
let link = dir.path().join("link");
symlink(&real, &link).unwrap();
let canonical = real.canonicalize().unwrap();
let filter = ExcludeFilter::new(&link, &["tests/fixtures/".to_string()]).unwrap();
let files = collect_r_files(std::slice::from_ref(&canonical), &filter).unwrap();
assert_eq!(files, vec![canonical.join("keep.R")]);
}
#[test]
fn a_verbatim_root_prefix_is_matchable_without_it() {
assert_eq!(
strip_verbatim_prefix(Path::new(r"\\?\D:\pkg")),
Some(PathBuf::from(r"D:\pkg"))
);
assert_eq!(
strip_verbatim_prefix(Path::new(r"\\?\UNC\server\share")),
Some(PathBuf::from(r"\\server\share"))
);
assert_eq!(strip_verbatim_prefix(Path::new("/project")), None);
}
}