use std::io::Write;
use std::path::Path;
use std::str::FromStr;
use anyhow::{Context, Result};
use itertools::Itertools;
use prek_consts::CONFIG_FILENAMES;
use crate::cli::reporter::HookRunReporter;
use crate::cli::run::{CollectOptions, FileFilter, collect_files};
use crate::config::{self, FilePattern, HookOptions, Language, MetaHook};
use crate::hook::Hook;
use crate::store::Store;
use crate::workspace::Project;
#[derive(Debug, Copy, Clone, PartialEq, Eq, strum::AsRefStr, strum::Display, 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<(i32, Vec<u8>)> {
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::new_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,
options: HookOptions {
files: Some(config_file_glob.clone()),
..Default::default()
},
},
MetaHooks::CheckUselessExcludes => MetaHook {
id: "check-useless-excludes".to_string(),
name: "Check useless excludes".to_string(),
priority: None,
options: HookOptions {
files: Some(config_file_glob),
..Default::default()
},
},
MetaHooks::Identity => MetaHook {
id: "identity".to_string(),
name: "identity".to_string(),
priority: None,
options: HookOptions {
verbose: Some(true),
..Default::default()
},
},
})
}
}
pub(crate) async fn check_hooks_apply(
store: &Store,
hook: &Hook,
filenames: &[&Path],
) -> Result<(i32, Vec<u8>)> {
let relative_path = hook.project().relative_path();
let input = collect_files(hook.work_dir(), CollectOptions::all_files()).await?;
let input: Vec<_> = input.into_iter().map(|f| relative_path.join(f)).collect();
let mut code = 0;
let mut output = Vec::new();
for filename in filenames {
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());
let filter = FileFilter::for_project(input.iter(), &project, None);
let project_hooks = project
.init_hooks(store, None)
.await
.context("Failed to init hooks")?;
for project_hook in project_hooks {
if project_hook.always_run || matches!(project_hook.language, Language::Fail) {
continue;
}
let filenames = filter.for_hook(&project_hook);
if filenames.is_empty() {
code = 1;
writeln!(
&mut output,
"{} does not apply to this repository",
project_hook.id
)?;
}
}
}
Ok((code, output))
}
fn excludes_any(
files: &[impl AsRef<Path>],
include: Option<&FilePattern>,
exclude: Option<&FilePattern>,
) -> bool {
if exclude.is_none() {
return true;
}
files.iter().any(|f| {
let Some(f) = f.as_ref().to_str() else {
return false; };
if let Some(pattern) = &include {
if !pattern.is_match(f) {
return false;
}
}
if let Some(pattern) = &exclude {
if !pattern.is_match(f) {
return false;
}
}
true
})
}
pub(crate) async fn check_useless_excludes(
hook: &Hook,
filenames: &[&Path],
) -> Result<(i32, Vec<u8>)> {
let relative_path = hook.project().relative_path();
let input_project = collect_files(hook.work_dir(), CollectOptions::all_files()).await?;
let input_workspace: Vec<_> = input_project
.iter()
.map(|f| relative_path.join(f))
.collect();
let mut code = 0;
let mut output = Vec::new();
for filename in filenames {
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());
let config = project.config();
if !excludes_any(&input_project, None, config.exclude.as_ref()) {
code = 1;
let display = config
.exclude
.as_ref()
.map(ToString::to_string)
.unwrap_or_default();
writeln!(
&mut output,
"The global exclude pattern `{display}` does not match any files"
)?;
}
let filter = FileFilter::for_project(input_workspace.iter(), &project, None);
for repo in &config.repos {
let hooks_iter: Box<dyn Iterator<Item = (&String, &HookOptions)>> = match repo {
config::Repo::Remote(r) => Box::new(r.hooks.iter().map(|h| (&h.id, &h.options))),
config::Repo::Local(r) => Box::new(r.hooks.iter().map(|h| (&h.id, &h.options))),
config::Repo::Meta(r) => Box::new(r.hooks.iter().map(|h| (&h.id, &h.options))),
config::Repo::Builtin(r) => Box::new(r.hooks.iter().map(|h| (&h.id, &h.options))),
};
for (hook_id, opts) in hooks_iter {
let filtered_files = filter.by_type(
opts.types.as_ref(),
opts.types_or.as_ref(),
opts.exclude_types.as_ref(),
);
let filtered_files_relative: Vec<&Path> = if relative_path.as_os_str().is_empty() {
filtered_files
} else {
filtered_files
.into_iter()
.filter_map(|f| f.strip_prefix(relative_path).ok())
.collect()
};
if !excludes_any(
&filtered_files_relative,
opts.files.as_ref(),
opts.exclude.as_ref(),
) {
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((code, output))
}
pub fn identity(_hook: &Hook, filenames: &[&Path]) -> (i32, Vec<u8>) {
(
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};
fn regex_pattern(pattern: &str) -> FilePattern {
FilePattern::new_regex(pattern).unwrap()
}
#[test]
fn test_excludes_any() {
let files = vec![
Path::new("file1.txt"),
Path::new("file2.txt"),
Path::new("file3.txt"),
];
let include = regex_pattern(r"file.*");
let exclude = regex_pattern(r"file2\.txt");
assert!(excludes_any(&files, Some(&include), Some(&exclude)));
let include = regex_pattern(r"file.*");
let exclude = regex_pattern(r"file4\.txt");
assert!(!excludes_any(&files, Some(&include), Some(&exclude)));
assert!(excludes_any(&files, None, None));
let files = vec![Path::new("html/file1.html"), Path::new("html/file2.html")];
let exclude = regex_pattern(r"^html/");
assert!(excludes_any(&files, None, Some(&exclude)));
}
#[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(PRE_COMMIT_CONFIG_YAML));
assert!(apply_files.is_match(PRE_COMMIT_CONFIG_YML));
assert!(apply_files.is_match(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(PRE_COMMIT_CONFIG_YAML));
assert!(useless_files.is_match(PRE_COMMIT_CONFIG_YML));
assert!(useless_files.is_match(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));
}
}