use std::ops::ControlFlow;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use itertools::{Either, Itertools};
use prek_consts::env_vars::EnvVars;
use prek_identify::{TagSet, tags_from_path};
use rustc_hash::{FxHashMap, FxHashSet};
use tracing::{debug, error, instrument};
use crate::config::{FilePattern, Stage};
use crate::fs::PathClean;
use crate::git::GIT_ROOT;
use crate::hook::Hook;
use crate::workspace::Project;
use crate::{fs, git, warn_user};
pub(crate) struct FilenameFilter<'a> {
include: Option<&'a FilePattern>,
exclude: Option<&'a FilePattern>,
}
impl<'a> FilenameFilter<'a> {
pub(crate) fn new(include: Option<&'a FilePattern>, exclude: Option<&'a FilePattern>) -> Self {
Self { include, exclude }
}
pub(crate) fn matches(&self, filename: &Path) -> bool {
if let Some(pattern) = &self.include {
if !pattern.is_match(filename) {
return false;
}
}
if let Some(pattern) = &self.exclude {
if pattern.is_match(filename) {
return false;
}
}
true
}
}
pub(crate) struct FileTagFilter<'a> {
all: Option<&'a TagSet>,
any: Option<&'a TagSet>,
exclude: Option<&'a TagSet>,
}
impl<'a> FileTagFilter<'a> {
pub(crate) fn new(
types: Option<&'a TagSet>,
types_or: Option<&'a TagSet>,
exclude_types: Option<&'a TagSet>,
) -> Self {
Self {
all: types,
any: types_or,
exclude: exclude_types,
}
}
pub(crate) fn matches(&self, file_types: &TagSet) -> bool {
if self.all.is_some_and(|s| !s.is_subset(file_types)) {
return false;
}
if self
.any
.is_some_and(|s| !s.is_empty() && s.is_disjoint(file_types))
{
return false;
}
if self.exclude.is_some_and(|s| !s.is_disjoint(file_types)) {
return false;
}
true
}
}
pub(crate) struct HookFileFilter<'a> {
filename: FilenameFilter<'a>,
tags: FileTagFilter<'a>,
}
impl<'a> HookFileFilter<'a> {
pub(crate) fn new(hook: &'a Hook) -> Self {
Self {
filename: FilenameFilter::new(hook.files.as_ref(), hook.exclude.as_ref()),
tags: FileTagFilter::new(
Some(&hook.types),
Some(&hook.types_or),
Some(&hook.exclude_types),
),
}
}
pub(crate) fn matches_filename(&self, filename: &Path) -> bool {
self.filename.matches(filename)
}
pub(crate) fn matches_tags(&self, tags: Option<&TagSet>) -> bool {
tags.is_some_and(|tags| self.tags.matches(tags))
}
pub(crate) fn matches_project_file<'p>(
&self,
file: &ProjectFile<'p>,
tag_cache: &mut FileTagCache<'p>,
) -> bool {
self.matches_filename(file.hook_path) && self.matches_tags(file.tags(tag_cache))
}
}
pub(crate) struct ProjectFile<'a> {
workspace_path: &'a Path,
hook_path: &'a Path,
}
impl<'a> ProjectFile<'a> {
fn new(workspace_path: &'a Path, hook_path: &'a Path) -> Self {
Self {
workspace_path,
hook_path,
}
}
pub(crate) fn hook_path(&self) -> &Path {
self.hook_path
}
pub(crate) fn tags<'cache>(
&self,
tag_cache: &'cache mut FileTagCache<'a>,
) -> Option<&'cache TagSet> {
tag_cache.tags(self.workspace_path)
}
}
#[derive(Default)]
pub(crate) struct FileTagCache<'a> {
tags_by_path: FxHashMap<&'a Path, Option<TagSet>>,
}
impl<'a> FileTagCache<'a> {
pub(crate) fn tags(&mut self, path: &'a Path) -> Option<&TagSet> {
if !self.tags_by_path.contains_key(path) {
let tags = match tags_from_path(path) {
Ok(tags) => Some(tags),
Err(err) => {
error!(filename = ?path.display(), error = %err, "Failed to get tags");
None
}
};
self.tags_by_path.insert(path, tags);
}
self.tags_by_path.get(path).and_then(Option::as_ref)
}
}
pub(crate) struct ProjectFiles<'a> {
files: Vec<ProjectFile<'a>>,
}
impl<'a> ProjectFiles<'a> {
pub(crate) fn for_project<I>(
filenames: I,
project: &Project,
consumed_files: Option<&mut FxHashSet<&'a Path>>,
) -> Self
where
I: Iterator<Item = &'a PathBuf> + Send,
{
let relative_path = project.relative_path();
let files_capacity = if relative_path.as_os_str().is_empty() {
filenames.size_hint().0
} else {
0
};
let mut files = Vec::with_capacity(files_capacity);
Self::visit_for_project(filenames, project, consumed_files, |file| {
files.push(file);
ControlFlow::Continue(())
});
Self { files }
}
pub(crate) fn consume_for_project<I>(
filenames: I,
project: &Project,
consumed_files: &mut FxHashSet<&'a Path>,
) where
I: Iterator<Item = &'a PathBuf> + Send,
{
Self::visit_for_project(filenames, project, Some(consumed_files), |_| {
ControlFlow::Continue(())
});
}
pub(crate) fn visit_for_project<I, F>(
filenames: I,
project: &Project,
mut consumed_files: Option<&mut FxHashSet<&'a Path>>,
mut visit: F,
) where
I: Iterator<Item = &'a PathBuf> + Send,
F: FnMut(ProjectFile<'a>) -> ControlFlow<()>,
{
let filename_filter = FilenameFilter::new(
project.config().files.as_ref(),
project.config().exclude.as_ref(),
);
let relative_path = project.relative_path();
let orphan = project.config().orphan.unwrap_or(false);
let must_finish_consuming = orphan && consumed_files.is_some();
let mut visiting = true;
for filename in filenames {
if !filename.starts_with(relative_path) {
continue;
}
if let Some(consumed_files) = consumed_files.as_mut() {
if orphan {
if !consumed_files.insert(filename) {
continue;
}
} else if consumed_files.contains(filename.as_path()) {
continue;
}
}
if !visiting {
continue;
}
let relative = filename
.strip_prefix(relative_path)
.expect("Filename should start with project relative path");
if filename_filter.matches(relative)
&& visit(ProjectFile::new(filename, relative)).is_break()
{
if must_finish_consuming {
visiting = false;
} else {
break;
}
}
}
}
pub(crate) fn len(&self) -> usize {
self.files.len()
}
#[instrument(level = "trace", skip_all, fields(hook = ?hook.id))]
pub(crate) fn matching_filenames(
&self,
hook: &Hook,
tag_cache: &mut FileTagCache<'a>,
) -> Vec<&Path> {
let hook_filter = HookFileFilter::new(hook);
let mut filenames = Vec::new();
for file in &self.files {
if hook_filter.matches_project_file(file, tag_cache) {
filenames.push(file.hook_path);
}
}
filenames
}
pub(crate) fn has_matching_file(&self, hook: &Hook, tag_cache: &mut FileTagCache<'a>) -> bool {
let hook_filter = HookFileFilter::new(hook);
for file in &self.files {
if hook_filter.matches_project_file(file, tag_cache) {
return true;
}
}
false
}
}
#[derive(Default)]
pub(crate) struct CollectOptions {
pub(crate) hook_stage: Stage,
pub(crate) from_ref: Option<String>,
pub(crate) to_ref: Option<String>,
pub(crate) all_files: bool,
pub(crate) files: Vec<String>,
pub(crate) directories: Vec<String>,
pub(crate) commit_msg_filename: Option<String>,
}
impl CollectOptions {
pub(crate) fn all_files() -> Self {
Self {
all_files: true,
..Default::default()
}
}
}
pub(crate) enum RunInput {
Files(Vec<PathBuf>),
MessageFile(PathBuf),
}
impl RunInput {
pub(crate) fn into_files(self) -> Vec<PathBuf> {
match self {
Self::Files(files) => files,
Self::MessageFile(_) => vec![],
}
}
}
pub(crate) async fn collect_run_input(root: &Path, opts: CollectOptions) -> Result<RunInput> {
let CollectOptions {
hook_stage,
from_ref,
to_ref,
all_files,
files,
directories,
commit_msg_filename,
} = opts;
if matches!(hook_stage, Stage::PrepareCommitMsg | Stage::CommitMsg) {
let path = commit_msg_filename.expect("commit_msg_filename should be set");
return Ok(RunInput::MessageFile(GIT_ROOT.as_ref()?.join(path)));
}
let git_root = GIT_ROOT.as_ref()?;
let relative_root = root.strip_prefix(git_root).with_context(|| {
format!(
"Workspace root `{}` is not under git root `{}`",
root.display(),
git_root.display()
)
})?;
let filenames = collect_files_from_args(
git_root,
root,
hook_stage,
from_ref,
to_ref,
all_files,
files,
directories,
)
.await?;
let mut filenames = filenames
.into_iter()
.filter_map(|filename| {
filename
.strip_prefix(relative_root)
.map(|p| fs::normalize_path(p.to_path_buf()))
.ok()
})
.collect::<Vec<_>>();
if EnvVars::is_set(EnvVars::PREK_INTERNAL__SORT_FILENAMES) {
filenames.sort_unstable();
}
Ok(RunInput::Files(filenames))
}
fn adjust_relative_path(path: &str, new_cwd: &Path) -> Result<PathBuf, std::io::Error> {
let absolute = std::path::absolute(path)?.clean();
fs::relative_to(absolute, new_cwd)
}
async fn collect_files_from_args(
git_root: &Path,
workspace_root: &Path,
hook_stage: Stage,
from_ref: Option<String>,
to_ref: Option<String>,
all_files: bool,
files: Vec<String>,
directories: Vec<String>,
) -> Result<Vec<PathBuf>> {
if !hook_stage.operate_on_files() {
return Ok(vec![]);
}
if let (Some(from_ref), Some(to_ref)) = (from_ref, to_ref) {
let files = git::get_changed_files(&from_ref, &to_ref, workspace_root).await?;
debug!(
"Files changed between {} and {}: {}",
from_ref,
to_ref,
files.len()
);
return Ok(files);
}
if !files.is_empty() || !directories.is_empty() {
let (exists, non_exists): (FxHashSet<_>, Vec<_>) =
files.into_iter().partition_map(|filename| {
if fs_err::exists(&filename).unwrap_or(false) {
Either::Left(filename)
} else {
Either::Right(filename)
}
});
if !non_exists.is_empty() {
if non_exists.len() == 1 {
warn_user!(
"This file does not exist and will be ignored: `{}`",
non_exists[0]
);
} else {
warn_user!(
"These files do not exist and will be ignored: `{}`",
non_exists.join(", ")
);
}
}
let mut exists = exists
.into_iter()
.map(|filename| adjust_relative_path(&filename, git_root).map(fs::normalize_path))
.collect::<Result<FxHashSet<_>, _>>()?;
for dir in directories {
let dir = adjust_relative_path(&dir, git_root)?;
let dir_files = git::ls_files(git_root, &dir).await?;
for file in dir_files {
let file = fs::normalize_path(file);
exists.insert(file);
}
}
debug!("Files passed as arguments: {}", exists.len());
return Ok(exists.into_iter().collect());
}
if all_files {
let files = git::ls_files(git_root, workspace_root).await?;
debug!("All files in the workspace: {}", files.len());
return Ok(files);
}
if git::is_in_merge_conflict().await? {
let files = git::get_conflicted_files(workspace_root).await?;
debug!("Conflicted files: {}", files.len());
return Ok(files);
}
let files = git::get_staged_files(workspace_root).await?;
debug!("Staged files: {}", files.len());
Ok(files)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::GlobPatterns;
fn glob_pattern(pattern: &str) -> FilePattern {
FilePattern::Glob(GlobPatterns::new(vec![pattern.to_string()]).unwrap())
}
fn regex_pattern(pattern: &str) -> FilePattern {
FilePattern::regex(pattern).unwrap()
}
#[test]
fn filename_filter_supports_glob_include_and_exclude() {
let include = glob_pattern("src/**/*.rs");
let exclude = glob_pattern("src/**/ignored.rs");
let filter = FilenameFilter::new(Some(&include), Some(&exclude));
assert!(filter.matches(Path::new("src/lib/main.rs")));
assert!(!filter.matches(Path::new("src/lib/ignored.rs")));
assert!(!filter.matches(Path::new("tests/main.rs")));
}
#[cfg(unix)]
#[test]
fn filename_filter_allows_non_utf8_paths_without_patterns() {
use std::ffi::OsStr;
use std::os::unix::ffi::OsStrExt as _;
let path = Path::new(OsStr::from_bytes(b"bad-\xff.py"));
let filter = FilenameFilter::new(None, None);
assert!(filter.matches(path));
}
#[cfg(unix)]
#[test]
fn filename_filter_matches_non_utf8_paths_with_glob_patterns() {
use std::ffi::OsStr;
use std::os::unix::ffi::OsStrExt as _;
let include = glob_pattern("**/*.py");
let exclude = glob_pattern("**/*.py");
let path = Path::new(OsStr::from_bytes(b"bad-\xff.py"));
let filter = FilenameFilter::new(Some(&include), None);
assert!(filter.matches(path));
let filter = FilenameFilter::new(None, Some(&exclude));
assert!(!filter.matches(path));
}
#[cfg(unix)]
#[test]
fn filename_filter_skips_non_utf8_paths_with_regex_include() {
use std::ffi::OsStr;
use std::os::unix::ffi::OsStrExt as _;
let include = regex_pattern(r".*\.py$");
let path = Path::new(OsStr::from_bytes(b"bad-\xff.py"));
let filter = FilenameFilter::new(Some(&include), None);
assert!(!filter.matches(path));
}
}