use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use anyhow::Result;
use ignore::WalkBuilder;
use crate::config::{CopFilterSet, ResolvedConfig};
pub struct DiscoveredFiles {
pub files: Vec<PathBuf>,
pub explicit: HashSet<PathBuf>,
}
pub fn discover_files(paths: &[PathBuf], config: &ResolvedConfig) -> Result<DiscoveredFiles> {
discover_files_filtered(paths, &CopFilterSet::for_discover(config), false)
}
pub fn discover_files_filtered(
paths: &[PathBuf],
filters: &CopFilterSet,
force_exclusion: bool,
) -> Result<DiscoveredFiles> {
let mut files = Vec::new();
let mut explicit = HashSet::new();
discover_emitting(
paths,
filters,
force_exclusion,
&AtomicBool::new(false),
|p| {
files.push(p);
},
)?;
for path in paths {
if path.is_file() {
explicit.insert(path.canonicalize().unwrap_or_else(|_| path.to_path_buf()));
}
}
files.sort();
files.dedup();
Ok(DiscoveredFiles { files, explicit })
}
pub fn discover_emitting(
paths: &[PathBuf],
filters: &CopFilterSet,
force_exclusion: bool,
stop: &AtomicBool,
mut emit: impl FnMut(PathBuf),
) -> Result<usize> {
let mut explicit = HashSet::new();
let mut count = 0usize;
let mut seen = HashSet::new();
for path in paths {
if stop.load(Ordering::Relaxed) {
break;
}
collect_emitting(
path,
filters,
force_exclusion,
stop,
&mut explicit,
&mut seen,
&mut count,
&mut emit,
)?;
}
Ok(count)
}
fn collect_emitting(
path: &Path,
filters: &CopFilterSet,
force_exclusion: bool,
stop: &AtomicBool,
explicit: &mut HashSet<PathBuf>,
seen: &mut HashSet<PathBuf>,
count: &mut usize,
emit: &mut impl FnMut(PathBuf),
) -> Result<()> {
if path.is_file() {
explicit.insert(path.canonicalize().unwrap_or_else(|_| path.to_path_buf()));
maybe_emit(
normalize_scan_path(path.to_path_buf()),
true,
force_exclusion,
filters,
seen,
count,
emit,
);
return Ok(());
}
if path.is_dir() {
walk_directory_emitting(path, filters, force_exclusion, stop, seen, count, emit)?;
return Ok(());
}
anyhow::bail!("path does not exist: {}", path.display());
}
fn maybe_emit(
path: PathBuf,
is_explicit: bool,
force_exclusion: bool,
filters: &CopFilterSet,
seen: &mut HashSet<PathBuf>,
count: &mut usize,
emit: &mut impl FnMut(PathBuf),
) {
if !seen.insert(path.clone()) {
return;
}
if is_explicit && !force_exclusion {
*count += 1;
emit(path);
return;
}
if filters.is_globally_excluded(&path) {
return;
}
*count += 1;
emit(path);
}
fn walk_directory_emitting(
dir: &Path,
filters: &CopFilterSet,
force_exclusion: bool,
stop: &AtomicBool,
seen: &mut HashSet<PathBuf>,
count: &mut usize,
emit: &mut impl FnMut(PathBuf),
) -> Result<()> {
let mut builder = WalkBuilder::new(dir);
builder
.hidden(true)
.git_ignore(true)
.git_global(true)
.follow_links(false);
for entry in builder.build() {
if stop.load(Ordering::Relaxed) {
break;
}
let Ok(entry) = entry else {
continue;
};
let path = entry.path();
if path.is_file() && is_ruby_file(path) {
maybe_emit(
normalize_scan_path(path.to_path_buf()),
false,
force_exclusion,
filters,
seen,
count,
emit,
);
}
}
Ok(())
}
fn normalize_scan_path(path: PathBuf) -> PathBuf {
let Some(s) = path.to_str() else {
return path;
};
match s.strip_prefix("./") {
Some(rest) => PathBuf::from(rest),
None => path,
}
}
const RUBY_EXTENSIONS: &[&str] = &[
"rb", "arb", "axlsx", "builder", "fcgi", "gemfile", "gemspec", "god", "jb", "jbuilder",
"mspec", "opal", "pluginspec", "podspec", "rabl", "rake", "rbuild", "rbw", "rbx", "ru",
"ruby", "schema", "spec", "thor", "watchr",
];
const RUBY_FILENAMES: &[&str] = &[
".irbrc",
".pryrc",
".simplecov",
"Appraisals",
"Berksfile",
"Brewfile",
"Buildfile",
"Capfile",
"Cheffile",
"Dangerfile",
"Deliverfile",
"Fastfile",
"Gemfile",
"Guardfile",
"Jarfile",
"Mavenfile",
"Podfile",
"Puppetfile",
"Rakefile",
"rakefile",
"Schemafile",
"Snapfile",
"Steepfile",
"Thorfile",
"Vagabondfile",
"Vagrantfile",
];
pub(crate) fn is_ruby_file(path: &Path) -> bool {
if let Some(ext) = path.extension().and_then(|e| e.to_str())
&& RUBY_EXTENSIONS.iter().any(|&r| r.eq_ignore_ascii_case(ext))
{
return true;
}
if let Some(name) = path.file_name().and_then(|n| n.to_str())
&& RUBY_FILENAMES.contains(&name)
{
return true;
}
path.extension().is_none() && has_ruby_shebang(path)
}
fn has_ruby_shebang(path: &Path) -> bool {
let Ok(bytes) = std::fs::read(path) else {
return false;
};
let Ok(text) = std::str::from_utf8(bytes.split(|&b| b == b'\n').next().unwrap_or(&[])) else {
return false;
};
text.starts_with("#!") && (text.contains("ruby") || text.contains("jruby"))
}