use std::io::Write;
use std::ops::ControlFlow;
use std::path::Path;
use std::str::FromStr;
use anyhow::{Context, Result};
use itertools::Itertools;
use prek_consts::CONFIG_FILENAMES;
use crate::cli::run::HookRunReporter;
use crate::cli::run::{
CollectOptions, FileTagCache, FileTagFilter, HookFileFilter, ProjectFiles, collect_run_input,
};
use crate::config::{FilePattern, HookOptions, Language, MetaHook};
use crate::hook::{Hook, Repo};
use crate::hooks::{BuiltinHooks, HookOutput};
use crate::store::Store;
use crate::workspace::{HookInitFilters, Project};
#[derive(
Debug,
Copy,
Clone,
PartialEq,
Eq,
strum::AsRefStr,
strum::Display,
strum::EnumIter,
strum::EnumString,
)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[cfg_attr(feature = "schemars", schemars(rename_all = "kebab-case"))]
#[strum(serialize_all = "kebab-case")]
pub(crate) enum MetaHooks {
CheckHooksApply,
CheckUselessExcludes,
Identity,
}
impl MetaHooks {
pub(crate) async fn run(
self,
store: &Store,
hook: &Hook,
filenames: &[&Path],
reporter: &HookRunReporter,
) -> Result<HookOutput> {
let progress = reporter.on_run_start(hook, filenames.len());
let result = match self {
Self::CheckHooksApply => check_hooks_apply(store, hook, filenames).await,
Self::CheckUselessExcludes => check_useless_excludes(hook, filenames).await,
Self::Identity => Ok(identity(hook, filenames)),
};
reporter.on_run_complete(progress);
result
}
}
impl MetaHook {
pub(crate) fn from_id(id: &str) -> Result<Self, ()> {
let hook_id = MetaHooks::from_str(id).map_err(|_| ())?;
let config_file_glob =
FilePattern::glob(CONFIG_FILENAMES.iter().map(ToString::to_string).collect()).unwrap();
Ok(match hook_id {
MetaHooks::CheckHooksApply => MetaHook {
id: "check-hooks-apply".to_string(),
name: "Check hooks apply".to_string(),
priority: None,
groups: None,
options: HookOptions {
files: Some(config_file_glob),
..Default::default()
},
},
MetaHooks::CheckUselessExcludes => MetaHook {
id: "check-useless-excludes".to_string(),
name: "Check useless excludes".to_string(),
priority: None,
groups: None,
options: HookOptions {
files: Some(config_file_glob),
..Default::default()
},
},
MetaHooks::Identity => MetaHook {
id: "identity".to_string(),
name: "identity".to_string(),
priority: None,
groups: None,
options: HookOptions {
verbose: Some(true),
..Default::default()
},
},
})
}
}
pub(crate) async fn check_hooks_apply(
store: &Store,
hook: &Hook,
filenames: &[&Path],
) -> Result<HookOutput> {
let projects = load_meta_projects(hook, filenames)?;
if projects.is_empty() {
return Ok(HookOutput::unchanged(0, Vec::new()));
}
let relative_path = hook.project().relative_path();
let input = collect_run_input(hook.work_dir(), CollectOptions::all_files())
.await?
.into_files();
let input: Vec<_> = input.into_iter().map(|f| relative_path.join(f)).collect();
let mut code = 0;
let mut output = Vec::new();
let tag_cache = FileTagCache::from_paths(&input);
for project in projects {
let project_hooks = project
.init_hooks(store, HookInitFilters::none(), None)
.await
.context("Failed to init hooks")?;
let hooks = project_hooks
.iter()
.filter(|hook| {
if hook.always_run || hook.language == Language::Fail {
return false;
}
!matches!(
hook.repo(),
Repo::Builtin if hook.id == BuiltinHooks::CheckIllegalWindowsNames.as_ref()
)
})
.collect::<Vec<_>>();
if hooks.is_empty() {
continue;
}
let filters = hooks
.iter()
.map(|hook| HookFileFilter::new(hook))
.collect::<Vec<_>>();
let mut matches = vec![false; hooks.len()];
let mut remaining = matches.len();
ProjectFiles::visit_for_project(input.iter(), hooks[0].project(), None, None, |file| {
let tags = file.tags(&tag_cache);
for (matched, filter) in matches.iter_mut().zip(&filters) {
if *matched {
continue;
}
if filter.matches_filename(file.hook_path()) && filter.matches_tags(tags) {
*matched = true;
remaining -= 1;
}
}
if remaining == 0 {
return ControlFlow::Break(());
}
ControlFlow::Continue(())
});
for (project_hook, matches) in hooks.iter().zip(matches) {
if !matches {
code = 1;
writeln!(
&mut output,
"{} does not apply to this repository",
project_hook.id
)?;
}
}
}
Ok(HookOutput::unchanged(code, output))
}
fn load_meta_projects(hook: &Hook, filenames: &[&Path]) -> Result<Vec<Project>> {
let relative_path = hook.project().relative_path();
filenames
.iter()
.map(|filename| {
let path = relative_path.join(filename);
let mut project = Project::from_config_file(path.into(), None)?;
project.with_relative_path(relative_path.to_path_buf());
Ok(project)
})
.collect()
}
fn matches_patterns(
filename: &Path,
include: Option<&FilePattern>,
exclude: Option<&FilePattern>,
) -> bool {
if let Some(pattern) = include {
if !pattern.is_match(filename) {
return false;
}
}
if let Some(pattern) = exclude {
if !pattern.is_match(filename) {
return false;
}
}
true
}
pub(crate) async fn check_useless_excludes(hook: &Hook, filenames: &[&Path]) -> Result<HookOutput> {
let projects = load_meta_projects(hook, filenames)?;
if projects.is_empty() {
return Ok(HookOutput::unchanged(0, Vec::new()));
}
let relative_path = hook.project().relative_path();
let input_project = collect_run_input(hook.work_dir(), CollectOptions::all_files())
.await?
.into_files();
let input_workspace: Vec<_> = input_project
.iter()
.map(|f| relative_path.join(f))
.collect();
let mut code = 0;
let mut output = Vec::new();
let tag_cache = FileTagCache::from_paths(&input_workspace);
for project in projects {
let config = project.config();
if let Some(exclude) = &config.exclude
&& !input_project.iter().any(|file| exclude.is_match(file))
{
code = 1;
writeln!(
&mut output,
"The global exclude pattern `{exclude}` does not match any files"
)?;
}
let mut hook_options = Vec::new();
for repo in &config.repos {
hook_options.extend(repo.hooks().map(|hook| (hook.id, hook.options)));
}
if hook_options.iter().all(|(_, opts)| opts.exclude.is_none()) {
continue;
}
let tag_filters = hook_options
.iter()
.map(|(_, opts)| {
FileTagFilter::new(
opts.types.as_ref(),
opts.types_or.as_ref(),
opts.exclude_types.as_ref(),
)
})
.collect::<Vec<_>>();
let mut exclude_matches = hook_options
.iter()
.map(|(_, opts)| opts.exclude.is_none())
.collect::<Vec<_>>();
let mut remaining = exclude_matches.iter().filter(|matched| !**matched).count();
ProjectFiles::visit_for_project(input_workspace.iter(), &project, None, None, |file| {
let tags = file.tags(&tag_cache);
for ((matched, (_, opts)), tag_filter) in exclude_matches
.iter_mut()
.zip(&hook_options)
.zip(&tag_filters)
{
if *matched || tags.is_none_or(|tags| !tag_filter.matches(tags)) {
continue;
}
if matches_patterns(file.hook_path(), opts.files.as_ref(), opts.exclude.as_ref()) {
*matched = true;
remaining -= 1;
}
}
if remaining == 0 {
return ControlFlow::Break(());
}
ControlFlow::Continue(())
});
for ((hook_id, opts), exclude_matches) in hook_options.iter().zip(exclude_matches) {
if !exclude_matches {
code = 1;
let display = opts
.exclude
.as_ref()
.map(ToString::to_string)
.unwrap_or_default();
writeln!(
&mut output,
"The exclude pattern `{display}` for `{hook_id}` does not match any files"
)?;
}
}
}
Ok(HookOutput::unchanged(code, output))
}
pub fn identity(_hook: &Hook, filenames: &[&Path]) -> HookOutput {
HookOutput::unchanged(
0,
filenames
.iter()
.map(|f| f.to_string_lossy())
.join("\n")
.into_bytes(),
)
}
#[cfg(test)]
mod tests {
use super::*;
use prek_consts::{PRE_COMMIT_CONFIG_YAML, PRE_COMMIT_CONFIG_YML, PREK_TOML};
#[test]
fn meta_hook_patterns_cover_config_files() {
let apply = MetaHook::from_id("check-hooks-apply").expect("known meta hook");
let apply_files = apply.options.files.as_ref().expect("files should be set");
assert!(apply_files.is_match(Path::new(PRE_COMMIT_CONFIG_YAML)));
assert!(apply_files.is_match(Path::new(PRE_COMMIT_CONFIG_YML)));
assert!(apply_files.is_match(Path::new(PREK_TOML)));
let useless = MetaHook::from_id("check-useless-excludes").expect("known meta hook");
let useless_files = useless.options.files.as_ref().expect("files should be set");
assert!(useless_files.is_match(Path::new(PRE_COMMIT_CONFIG_YAML)));
assert!(useless_files.is_match(Path::new(PRE_COMMIT_CONFIG_YML)));
assert!(useless_files.is_match(Path::new(PREK_TOML)));
let identity = MetaHook::from_id("identity").expect("known meta hook");
assert!(identity.options.files.is_none());
assert_eq!(identity.options.verbose, Some(true));
}
}